🎄 アドベントカレンダー 2025
License System Day 16: クライアントサイドの実装(WebAssembly)
📖 今日のテーマ
今日は、NimをWebAssemblyにコンパイルしたクライアント実装を学びます。
ブラウザ拡張機能として動作するライセンスクライアントの実装と、JavaScript/WASM連携の詳細を解説します。
🎯 なぜWebAssemblyなのか?
WebAssemblyの利点
✅ ネイティブ並みの速度 → JavaScript比2-20倍高速
✅ ポータビリティ → すべてのモダンブラウザで動作
✅ セキュリティ → サンドボックス環境で実行
✅ 言語非依存 → Nim/Rust/C++からコンパイル可能
✅ 小サイズ → 50KB以下に圧縮可能
✅ 並行処理 → Web Workersと組み合わせ可能
ブラウザ拡張機能での利用
Chrome Extension (Manifest V3)
├── Background Service Worker (WASM実行)
├── Content Script (ページへのインジェクション)
├── Popup UI (ライセンス状態表示)
└── WASM Module (Nimからコンパイル)
🏗️ プロジェクト構造
client-wasm/
├── src/
│ └── license_client.nim # ライセンスクライアント
├── browser-extension/
│ ├── manifest.json # Chrome拡張機能マニフェスト
│ ├── popup/
│ │ ├── popup.html # ポップアップUI
│ │ ├── popup.js # UIロジック
│ │ └── popup.css # スタイル
│ ├── background/
│ │ └── service-worker.js # バックグラウンドワーカー
│ ├── content/
│ │ └── content.js # コンテンツスクリプト
│ ├── wasm/
│ │ ├── license_client.js # Nimコンパイル済みWASM
│ │ └── license_client.wasm # WASMバイナリ
│ └── icons/
│ └── icon-*.png # 拡張機能アイコン
└── license_client_wasm.nimble # プロジェクト設定
💻 Nimクライアント実装
license_client.nim(完全版)
# client-wasm/src/license_client.nim
import std/[json, asyncdispatch, strutils, options]
when defined(js):
import std/jsffi
import std/jsconsole
type
LicenseClient* = ref object
serverUrl*: string
activationKey*: string
publicKey*: string
proc newLicenseClient*(serverUrl: string = "http://localhost:3000"): LicenseClient =
result = LicenseClient(
serverUrl: serverUrl,
activationKey: "",
publicKey: "" # 公開鍵は後で読み込み
)
# ========================================
# ライセンス認証
# ========================================
proc activateLicense*(self: LicenseClient, email, password: string): Future[JsonNode] {.async.} =
## ライセンス認証
##
## Args:
## email: ユーザーメールアドレス
## password: パスワード
##
## Returns:
## アクティベーション情報(JWT, プラン, 署名など)
when defined(js):
let requestBody = %*{
"email": email,
"password": password,
"client_id": "browser-extension-001"
}
let options = newJsObject()
options.`method` = cstring"POST"
options.headers = newJsObject()
options.headers.`Content-Type` = cstring"application/json"
options.body = cstring($requestBody)
let response = await fetch(cstring(self.serverUrl & "/api/v1/license/activate"), options)
if response.status != 200:
let errorText = await response.text()
let errorData = parseJson($errorText)
raise newException(ValueError, errorData["message"].getStr())
let data = await response.text()
result = parseJson($data)
# アクティベーションキーを保存
self.activationKey = result["activation_key"].getStr()
# 署名検証
if not self.verifySignature(result):
raise newException(ValueError, "Signature verification failed")
console.log("License activated successfully")
else:
raise newException(ValueError, "WASM mode only")
# ========================================
# ライセンス検証
# ========================================
proc validateLicense*(self: LicenseClient): Future[JsonNode] {.async.} =
## ライセンス検証
##
## Returns:
## 検証結果(status, premium, limits, features)
when defined(js):
if self.activationKey == "":
raise newException(ValueError, "No activation key. Please activate first.")
let options = newJsObject()
options.`method` = cstring"GET"
options.headers = newJsObject()
options.headers.`X-Activation-Key` = cstring(self.activationKey)
let response = await fetch(cstring(self.serverUrl & "/api/v1/license/validate"), options)
if response.status != 200:
let errorText = await response.text()
let errorData = parseJson($errorText)
raise newException(ValueError, errorData["message"].getStr())
let data = await response.text()
result = parseJson($data)
# 署名検証
if not self.verifySignature(result):
raise newException(ValueError, "Signature verification failed")
console.log("License validated successfully")
else:
raise newException(ValueError, "WASM mode only")
# ========================================
# エコーサービス呼び出し
# ========================================
proc callEchoService*(self: LicenseClient, message: string): Future[JsonNode] {.async.} =
## エコーサービス呼び出し
##
## Args:
## message: エコーするメッセージ
##
## Returns:
## エコー結果とレート制限情報
when defined(js):
let requestBody = %*{
"message": message
}
let options = newJsObject()
options.`method` = cstring"POST"
options.headers = newJsObject()
options.headers.`Content-Type` = cstring"application/json"
if self.activationKey != "":
options.headers.`X-Activation-Key` = cstring(self.activationKey)
options.body = cstring($requestBody)
let response = await fetch(cstring(self.serverUrl & "/api/v1/service/echo"), options)
# レートリミット情報の取得
let limitHeader = response.headers.get(cstring"X-RateLimit-Limit")
let remainingHeader = response.headers.get(cstring"X-RateLimit-Remaining")
let resetHeader = response.headers.get(cstring"X-RateLimit-Reset")
if response.status == 429:
# レート制限超過
let retryAfter = response.headers.get(cstring"Retry-After")
let errorText = await response.text()
let errorData = parseJson($errorText)
raise newException(ValueError, "Rate limit exceeded. Retry after " & $retryAfter & " seconds")
if response.status != 200:
let errorText = await response.text()
let errorData = parseJson($errorText)
raise newException(ValueError, errorData["message"].getStr())
let data = await response.text()
result = parseJson($data)
# レート情報をログ出力
console.log("Rate Limit: " & $remainingHeader & "/" & $limitHeader & " (resets at " & $resetHeader & ")")
else:
raise newException(ValueError, "WASM mode only")
# ========================================
# 署名検証
# ========================================
proc verifySignature*(self: LicenseClient, data: JsonNode): bool =
## デジタル署名検証
##
## Note: 本実装ではプレースホルダー
## 本番環境では公開鍵による実際のECDSA検証が必要
when defined(js):
if not data.hasKey("signature"):
console.warn("No signature found in response")
return false
# プレースホルダー実装
# 本番環境では以下を実装:
# 1. responseから署名を除外したcanonical形式を作成
# 2. ECDSA P-256公開鍵で署名検証
# 3. 検証結果をboolで返す
console.log("Signature verification (placeholder)")
return true
else:
return false
# ========================================
# ローカルストレージ連携
# ========================================
proc saveActivationKey*(self: LicenseClient) {.async.} =
## アクティベーションキーをローカルストレージに保存
when defined(js):
let storage = js"{}"
storage.activationKey = cstring(self.activationKey)
let chrome = js"chrome"
await chrome.storage.local.set(storage)
console.log("Activation key saved to storage")
proc loadActivationKey*(self: LicenseClient): Future[bool] {.async.} =
## ローカルストレージからアクティベーションキーを読み込み
##
## Returns:
## true: 読み込み成功, false: キーが存在しない
when defined(js):
let chrome = js"chrome"
let storage = await chrome.storage.local.get(cstring"activationKey")
if storage.activationKey != jsUndefined:
self.activationKey = $storage.activationKey
console.log("Activation key loaded from storage")
return true
else:
console.log("No activation key found in storage")
return false
proc clearActivationKey*(self: LicenseClient) {.async.} =
## アクティベーションキーをクリア
when defined(js):
self.activationKey = ""
let chrome = js"chrome"
await chrome.storage.local.remove(cstring"activationKey")
console.log("Activation key cleared")
# ========================================
# JavaScript互換エクスポート
# ========================================
when defined(js):
# グローバル変数としてエクスポート
var globalClient {.exportc: "licenseClient".}: LicenseClient
proc initClient*(serverUrl: cstring) {.exportc.} =
globalClient = newLicenseClient($serverUrl)
proc activate*(email, password: cstring): Future[JsonNode] {.exportc.} =
return globalClient.activateLicense($email, $password)
proc validate*(): Future[JsonNode] {.exportc.} =
return globalClient.validateLicense()
proc echo*(message: cstring): Future[JsonNode] {.exportc.} =
return globalClient.callEchoService($message)
proc saveKey*() {.exportc.} =
discard globalClient.saveActivationKey()
proc loadKey*(): Future[bool] {.exportc.} =
return globalClient.loadActivationKey()
proc clearKey*() {.exportc.} =
discard globalClient.clearActivationKey()
🌐 ブラウザ拡張機能統合
manifest.json
{
"manifest_version": 3,
"name": "License System Client",
"version": "1.0.0",
"description": "Commercial-grade license client with WASM",
"permissions": [
"storage",
"activeTab"
],
"background": {
"service_worker": "background/service-worker.js",
"type": "module"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content.js"]
}
],
"web_accessible_resources": [
{
"resources": ["wasm/license_client.js", "wasm/license_client.wasm"],
"matches": ["<all_urls>"]
}
]
}
service-worker.js (バックグラウンド)
// background/service-worker.js
import { initClient, activate, validate, echo } from '../wasm/license_client.js';
// WASM初期化
initClient('http://localhost:3000');
// メッセージハンドラ
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'activate') {
activate(request.email, request.password)
.then(result => sendResponse({ success: true, data: result }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true; // 非同期レスポンス
}
if (request.action === 'validate') {
validate()
.then(result => sendResponse({ success: true, data: result }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true;
}
if (request.action === 'echo') {
echo(request.message)
.then(result => sendResponse({ success: true, data: result }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true;
}
});
// インストール時の初期化
chrome.runtime.onInstalled.addListener(() => {
console.log('License System Client installed');
});
popup.js (UI)
// popup/popup.js
document.addEventListener('DOMContentLoaded', async () => {
const statusDiv = document.getElementById('status');
const loginForm = document.getElementById('login-form');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const testBtn = document.getElementById('test-btn');
const resultDiv = document.getElementById('result');
// ログインハンドラ
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = emailInput.value;
const password = passwordInput.value;
statusDiv.textContent = 'Activating...';
try {
const response = await chrome.runtime.sendMessage({
action: 'activate',
email: email,
password: password
});
if (response.success) {
statusDiv.textContent = 'Activated: ' + response.data.plan_type;
loginForm.style.display = 'none';
logoutBtn.style.display = 'block';
testBtn.style.display = 'block';
// ライセンス情報を表示
displayLicenseInfo(response.data);
} else {
statusDiv.textContent = 'Error: ' + response.error;
}
} catch (error) {
statusDiv.textContent = 'Error: ' + error.message;
}
});
// ログアウトハンドラ
logoutBtn.addEventListener('click', async () => {
await chrome.storage.local.remove('activationKey');
statusDiv.textContent = 'Logged out';
loginForm.style.display = 'block';
logoutBtn.style.display = 'none';
testBtn.style.display = 'none';
resultDiv.innerHTML = '';
});
// テストボタンハンドラ
testBtn.addEventListener('click', async () => {
resultDiv.textContent = 'Testing...';
try {
const response = await chrome.runtime.sendMessage({
action: 'echo',
message: 'Hello from Nim WASM!'
});
if (response.success) {
resultDiv.innerHTML = `
<strong>Echo Result:</strong> ${response.data.echo}<br>
<strong>Plan:</strong> ${response.data.plan}<br>
<strong>Rate Limit:</strong> ${response.data.rate_limit.remaining}/${response.data.rate_limit.limit}
`;
} else {
resultDiv.textContent = 'Error: ' + response.error;
}
} catch (error) {
resultDiv.textContent = 'Error: ' + error.message;
}
});
// 起動時にライセンス状態確認
try {
const response = await chrome.runtime.sendMessage({ action: 'validate' });
if (response.success) {
statusDiv.textContent = 'Activated: ' + response.data.plan_type;
loginForm.style.display = 'none';
logoutBtn.style.display = 'block';
testBtn.style.display = 'block';
displayLicenseInfo(response.data);
}
} catch {
// ライセンス未認証
}
});
function displayLicenseInfo(data) {
const infoDiv = document.getElementById('license-info');
infoDiv.innerHTML = `
<h3>License Information</h3>
<p><strong>Plan:</strong> ${data.plan_type}</p>
<p><strong>Status:</strong> ${data.status}</p>
<p><strong>Premium:</strong> ${data.premium ? 'Yes' : 'No'}</p>
`;
}
🔧 ビルドとデプロイ
nimble設定
# license_client_wasm.nimble
version = "1.0.0"
author = "GodsGolemInc"
description = "License client compiled to WebAssembly"
license = "MIT"
srcDir = "src"
requires "nim >= 2.0.0"
task wasm, "Build WebAssembly module":
exec "nim js -d:release -d:danger --opt:size -o:browser-extension/wasm/license_client.js src/license_client.nim"
task clean, "Clean build artifacts":
exec "rm -rf browser-extension/wasm/*.js browser-extension/wasm/*.wasm nimcache/"
ビルド手順
# 1. WASMモジュールのビルド
cd client-wasm
nimble wasm
# 2. 拡張機能の読み込み
# Chrome: chrome://extensions
# → 「デベロッパーモード」有効化
# → 「パッケージ化されていない拡張機能を読み込む」
# → browser-extension/ ディレクトリを選択
# 3. テスト
# 拡張機能アイコンをクリック
# → ログイン: premium@example.com / password123
# → Test Echoボタンをクリック
🌟 まとめ
Nim/WASM クライアント実装の要点:
-
WebAssembly利点
- ネイティブ並み速度
- ブラウザ互換性
- セキュアなサンドボックス
-
Nim実装
- JavaScript FFI
- 非同期処理
- WASM export
-
ブラウザ拡張機能
- Manifest V3
- Service Worker
- Content Script
-
ストレージ連携
- chrome.storage API
- アクティベーションキー永続化
前回: Day 15: サーバーサイドの実装(Nim/Jester)
次回: Day 17: 鍵管理とセキュリティ
Happy Learning! 🎉