はじめに

「AIが生成したコードをいきなり実行する」と言われたら、多くのエンジニアは不安を覚えますよね。LLMは便利ですが、生成されたコードをそのまま実行することには常にリスクが伴います。

今回活用する Cloudflare Dynamic Workers は、実行時に生成されたコードを V8 isolate 内で安全に実行できる仕組みです。本記事では Dynamic Workers の安全性を整理し、そのユースケースとして IoTセンサーのバイナリ電文を動的にパースする 題材を取り上げて考えてみます。

Dynamic Workers とは

Dynamic Workers は、Cloudflare Workers の有料プランで利用できるオープンベータの機能です。親 Worker が実行時にコード文字列から新しい Worker を生成し、それぞれが独自の V8 isolate で動作します。

公式ブログでは「eval() のセキュアバージョン」と表現されています。eval()new Function() は危険な方法ですが、Dynamic Workers はこれを高速起動するサンドボックス内での実行に留める事で解決しようという仕組みです。

方式 ネットワーク バインディング 隔離
eval() / new Function() アクセス可能 全アクセス可能 なし(同一プロセス)
Dynamic Workers 遮断可能globalOutbound: null 明示付与のみ V8 isolate

V8 isolate はコンテナと比べて「約100倍速く起動し、メモリ効率も10〜100倍」(Cloudflare公式)とされています。

Dynamic Workers が面白い点と用途案

Dynamic Workers の利点は「動的にコードを生成できること」ではなく、信頼できないコードを隔離して実行できることにあります。V8 isolate による分離・globalOutbound: null による通信遮断・明示的なバインディング付与によって、コードが利用できる権限を最小限に絞り込めます。

Dynamic Workers は、AIが生成したコードを実行するためのサンドボックス に使いやすいです。そこで今回は IoTデータの動的な解析 への活用を考えてみました。「実際に機材を入手して動かすまで、実行したいロジックを事前に確定できない」というケースが多いためです。

IoTで発生する現実的な課題

工場や施設の環境モニタリングでは、温湿度・CO2・振動など複数種類かつ大量のセンサーがデータを送信します。問題は、センサーのメーカーや世代ごとにバイナリフォーマットが微妙に異なる ことです。新しいセンサーを追加するたびに以下のサイクルが発生し、センサーの種類が増えると大変な状況になります。

仕様書 → 実装 → テスト → デプロイ

Dynamic Workers × AIが生成したパーサを使う

そのため Dynamic Workers を利用して工程を以下にしてみよう、が今回の案です。

仕様書 → LLMでパーサ生成 → 安全な isolate で実行

本記事の実装は、「準備フェーズ」と「実行フェーズ」の2つに分かれており、具体的には以下の3ステップで動作します。

準備フェーズ(/register)— Dynamic Worker はまだ起動しません

  1. プロトコル仕様を自然言語で POST する/register エンドポイント。通常は認証とか付けないとダメですが、、、)
  2. Workers AI がパーサーの JavaScript コードを自動生成し、KV にキャッシュする(=コードを書いて置いておくだけ)

実行フェーズ(/parse)— ここで初めて Dynamic Worker が起動します

  1. センサーからバイナリ電文が届いたら、KV のコードを Dynamic Worker に読み込ませてパースし JSON を返す/parse エンドポイント)。Dynamic Worker は protocolId ごとに isolate をキャッシュするので、起動コストがかかるのは各 protocolId の初回だけです

2回目以降は KV からキャッシュ済みコードを取得するだけなので、LLM呼び出しは仕様書を渡す時のみです。

サンプルで使うバイナリ電文仕様

環境センサーがあると想定して以下のようなtxtファイルを仕様書として作成してみました。

環境センサーのバイナリ仕様書

Byte 0:      ヘッダ(固定値 0xAA)
Byte 1:      デバイスタイプ
               0x01 = 温湿度センサー
               0x02 = CO2センサー
               0x03 = 振動センサー
Byte 2-3:    デバイスID(uint16, big-endian)

--- デバイスタイプ 0x01(温湿度)の場合 ---
  Byte 4-5:  温度(int16, big-endian, 0.01℃単位)
  Byte 6-7:  湿度(uint16, big-endian, 0.01%単位)

--- デバイスタイプ 0x02(CO2)の場合 ---
  Byte 4-5:  CO2濃度(uint16, big-endian, ppm)

--- デバイスタイプ 0x03(振動)の場合 ---
  Byte 4-5:  X軸加速度(int16, big-endian, 0.001G単位)
  Byte 6-7:  Y軸加速度(int16, big-endian, 0.001G単位)

最終Byte:    CRCチェックサム(全ペイロードバイトのXOR)

たとえば温湿度センサー(ID=42)が25.22℃ / 55.20%を送信する場合、電文は以下のようになります。

AA 01 00 2A 09 DA 15 90 D7
│  │  └──┘  └──┘  └──┘  └─ CRC(XOR)
│  │  ID=42 25.22℃ 55.20%
│  └─ Type=温湿度
└─── Header

事前準備(開発環境のセットアップ)

この記事の作業はCLIで実行します。ダッシュボードを使うのは wrangler の初回ログイン認証のみです。

必要なもの: Cloudflareアカウント(無料で作成可能)、Node.js(最新のLTS版を推奨)

1. プロジェクトを作成する

Cloudflare公式の create-cloudflare(C3)を使うと、プロジェクトの雛形作成とあわせて Wrangler(CloudflareのCLI)も自動でローカルにインストールされます。

npm create cloudflare@latest iot-edge-parser

対話プロンプトでは、テンプレートに「Hello World」→「Worker only」、言語に「TypeScript」を選び、最初のデプロイは「No」を選んでおきます。

Wrangler はグローバルではなくプロジェクトごとにローカル導入されるのが推奨構成です。コマンドは npx wrangler ... の形で実行します。

2. Cloudflareアカウントにログインする

cd iot-edge-parser
npx wrangler login

ブラウザが開き、Cloudflareへのログインと許可確認画面が表示されます。許可するとCLIが認証されます。

実装

以降のコードは要点を絞った抜粋です。全ファイルは本記事末尾の 「サンプルコード全文」 にまとめています。

プロジェクト構成

iot-edge-parser/
├── wrangler.jsonc          # Wrangler 設定
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts            # 親 Worker(ルーティング・LLM呼び出し・Dynamic Worker実行)
│   ├── types.ts            # 型定義
│   └── prompt.ts           # LLM プロンプトテンプレート
└── test.sh                 # curl テストスクリプト

Wrangler 設定(wrangler.jsonc)

{
  "name": "iot-edge-parser",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",

  // Dynamic Workers のローダーバインディング
  "worker_loaders": [
    { "binding": "PARSER_LOADER" }
  ],

  // KV: 生成済みパーサーコードのキャッシュ
  "kv_namespaces": [
    { "binding": "PARSER_CACHE", "id": "" }
  ],

  // Workers AI
  "ai": { "binding": "AI" }
}

worker_loaders が Dynamic Workers を使うためのバインディングです。これを宣言するだけで env.PARSER_LOADER を通じて動的にWorkerを生成できます。

親 Worker の部分(src/index.ts 抜粋)

プロトコル登録(/register

async function handleRegister(request: Request, env: Env): Promise {
  const body = (await request.json()) as RegisterRequest;

  // Workers AI でパーサーコードを生成
  const prompt = buildParserPrompt(body.spec, body.thresholds);
  const aiResponse = await env.AI.run(
    "@cf/meta/llama-4-scout-17b-16e-instruct",
    { messages: [{ role: "user", content: prompt }], max_tokens: 2048 }
  );

  const generatedCode = extractCode(aiResponse.response ?? "");

  // KV にキャッシュ
  await env.PARSER_CACHE.put(
    `parser:${body.protocolId}`,
    JSON.stringify({ code: generatedCode, spec: body.spec, thresholds: body.thresholds })
  );

  return Response.json({ message: "パーサーを生成・登録しました" });
}

Workers AI の llama-4-scout-17b-16e-instruct で自然言語仕様からパーサーコードを生成し、KVに保存します。2回目以降のLLM呼び出しはありません。

バイナリ電文パース(/parse)— ここで Dynamic Workers を使う

async function handleParse(request: Request, env: Env): Promise {
  const protocolId = request.headers.get("X-Protocol-Id");

  // KV からパーサーコードを取得
  const cached = await env.PARSER_CACHE.get(`parser:${protocolId}`);
  const entry = JSON.parse(cached) as CachedParser;

  // ★ Dynamic Worker を生成・実行
  const dynamicWorker = env.PARSER_LOADER.get(
    `parser-${protocolId}`,             // キャッシュ ID
    async () => ({
      compatibilityDate: "2026-06-01",
      mainModule: "parser.js",
      modules: {
        "parser.js": entry.code,        // AI が生成したパーサーコード
      },
      globalOutbound: null,             // ★ ネットワーク完全遮断
      // バインディングを一切渡さない → シークレット漏洩リスクゼロ
    })
  );

  // バイナリデータを Dynamic Worker に転送
  const binaryData = await request.arrayBuffer();
  const innerRequest = new Request("https://dummy/parse", {
    method: "POST",
    body: binaryData,
  });

  const innerResponse = await dynamicWorker.getEntrypoint().fetch(innerRequest);
  return innerResponse;
}

ポイントは2つです。

env.PARSER_LOADER.get() によるキャッシュ: load() ではなく get() を使うことで、同じ protocolId に対するリクエストでは既存のisolateを再利用します。

globalOutbound: null: この1行で、Dynamic Worker内からの外部ネットワーク通信を完全に遮断します。

LLM プロンプト(src/prompt.ts 抜粋)

プロンプトは ClaudeCode に下書きしてもらい、実際に動かしながら調整しました。Dynamic Worker の制約や仕様のエンディアンを明示的に書かないと、LLM は動作しないコードを生成します。何度か書き直して最終的に使い物になったのが次のプロンプトです。

export function buildParserPrompt(spec: string, thresholds?: Record): string {
  return `あなたは IoT バイナリプロトコルのエキスパートです。
以下のバイナリ電文仕様に基づいて、JavaScript のパーサーコードを生成してください。

## バイナリ電文仕様
${spec}

## 生成するコードの要件
1. export default { async fetch(request) { ... } } の形式
2. request.body を ArrayBuffer として読み取り、DataView でパース
   ★ big-endian 指定のフィールドは getInt16(offset, false) のように第2引数を false に
     (省略・true は little-endian になり誤った値になる)
3. ★ 必ず new Response(JSON.stringify(result), ...) を返す(素のオブジェクトを return しない)
4. deviceType は数値コードでなく文字列名("temperature_humidity" 等)で返す
5. CRC チェックサムの検証を実装
6. 閾値超過時は alerts 配列に追加

## 制約
- Web 標準 API のみ使用可(DataView, Uint8Array 等)
- Node.js の Buffer は使用不可
- fetch() や外部通信は使用不可(実行環境でブロックされます)
- import 文は使用不可(単一ファイルで完結)

コードのみを出力してください。`;
}

「fetch() は使用不可」と明示すれば外部通信を含むコードの生成確率を下げられます。仮にLLMが無視しても globalOutbound: null がランタイムレベルでブロックできます。

実際に動かして検証してわかったこと

ローカル(wrangler dev)で温湿度・CO2の電文をパースさせてみました。サンドボックスの仕組みは完璧に機能した一方で、AIが生成したパーサーは最初から正しくは動きませんでした。

最初の生成では、仕様書に big-endian と書いてあるのに第2引数を true(little-endian)にしてしまい、25.22℃ のはずが -97.19 という変な値になりました。プロンプトで直すと今度は new Response(...) で包み忘れて500エラー、、、結局3回ほど指示を足してようやく正しく動きました。

AIが間違ったコードを書いても、隔離されたisolateの中で変な値を返すだけで、「安全に失敗できる」ところが良いですね。正しさはプロンプトの作り込みと出力検証で担保する、というのが良さそうでした。

本番投入する前に足すべきこと

注意する点ですが、Dynamic Workers の isolate が守ってくれるのは「実行時の隔離」だけです。「悪いコードを隔離して安全に走らせる」仕組みであって、「悪いコードや不正な値がそもそも入ってこないようにする」仕組みではありません。デモとしてはこのままで十分動きますが、本番に載せるなら、以下の点を考えないとダメかもしれません。

1. 入力(/register)の保護: 本記事の /register は無認証で、生成コードは検証なしで KV に保存され /parse でそのまま実行されます。書き込み経路が汚染されれば保存コード自体が信用できないので、認証付与と生成コードの静的チェック(許可APIのホワイトリスト化など)でKVに入る前に弾く仕組みが要ります。

2. 出力のスキーマ検証: 現状の /parse は isolate が返した JSON を無検証で ParseResult として扱っています。誤った値が「正常な JSON」として下流に流れると誤検知・誤制御に直結するので、戻り値をスキーマ検証し、合致しなければ status: "error" とする一段を親 Worker に挟むのが安全です。

3. バインディング追加時の権限管理: 今回はバインディングを一切渡していないためシークレット漏洩リスクはゼロですが、これは「渡さない運用」が前提です。1つでも渡すとその権限は生成コードからフル利用できてしまうので、追加時は必要最小限に絞ってください。

いずれも「isolate の外側」で担保すべきですね。

実際にAIが生成したパーサコード

参考までに、調整後に Workers AI(llama-4-scout-17b-16e-instruct)が実際に出力したコードがこちらです(KVに保存されていた実物そのまま)。getInt16(offset, false) で big-endian を正しく扱い、new Response(...) で返せています。deviceType 変数への代入が使われないまま残っているなど細かい粗さはありますが、ちゃんと動きました。

export default {
  async fetch(request) {
    try {
      const arrayBuffer = await request.arrayBuffer();
      const dataView = new DataView(arrayBuffer);
      const uint8Array = new Uint8Array(arrayBuffer);

      // ヘッダ検証
      if (dataView.getUint8(0) !== 0xAA) {
        return new Response(JSON.stringify({ status: "error" }), {
          headers: { "Content-Type": "application/json" },
        });
      }

      // デバイスタイプ
      let deviceType;
      let deviceTypeString;
      switch (dataView.getUint8(1)) {
        case 0x01:
          deviceTypeString = "temperature_humidity";
          break;
        case 0x02:
          deviceTypeString = "co2";
          break;
        case 0x03:
          deviceTypeString = "vibration";
          break;
        default:
          return new Response(JSON.stringify({ status: "error" }), {
            headers: { "Content-Type": "application/json" },
          });
      }
      deviceType = dataView.getUint8(1);

      // デバイスID
      const deviceId = dataView.getUint16(2, false);

      // CRCチェックサム計算
      let crc = 0;
      for (let i = 0; i < arrayBuffer.byteLength - 1; i++) {
        crc ^= uint8Array[i];
      }
      if (crc !== uint8Array[arrayBuffer.byteLength - 1]) {
        return new Response(JSON.stringify({ status: "error" }), {
          headers: { "Content-Type": "application/json" },
        });
      }

      // 値読み取り
      let values = {};
      let alerts = [];
      if (deviceTypeString === "temperature_humidity") {
        const temperature = dataView.getInt16(4, false) * 0.01;
        const humidity = dataView.getUint16(6, false) * 0.01;

        values.temperature = temperature;
        values.humidity = humidity;

        if (temperature < -20 || temperature > 60) {
          alerts.push({
            field: "temperature",
            value: temperature,
            reason: `温度が範囲外 (${temperature} ℃)`,
          });
        }
        if (humidity < 0 || humidity > 100) {
          alerts.push({
            field: "humidity",
            value: humidity,
            reason: `湿度が範囲外 (${humidity} %)`,
          });
        }
      } else if (deviceTypeString === "co2") {
        const co2Ppm = dataView.getUint16(4, false);
        values.co2_ppm = co2Ppm;

        if (co2Ppm < 0 || co2Ppm > 2000) {
          alerts.push({
            field: "co2_ppm",
            value: co2Ppm,
            reason: `CO2濃度が範囲外 (${co2Ppm} ppm)`,
          });
        }
      } else if (deviceTypeString === "vibration") {
        const accelX = dataView.getInt16(4, false) * 0.001;
        const accelY = dataView.getInt16(6, false) * 0.001;

        values.accel_x = accelX;
        values.accel_y = accelY;

        if (accelX < -2 || accelX > 2) {
          alerts.push({
            field: "accel_x",
            value: accelX,
            reason: `X軸加速度が範囲外 (${accelX} G)`,
          });
        }
        if (accelY < -2 || accelY > 2) {
          alerts.push({
            field: "accel_y",
            value: accelY,
            reason: `Y軸加速度が範囲外 (${accelY} G)`,
          });
        }
      }

      let status = "ok";
      if (alerts.length > 0) {
        status = "alert";
      }

      const result = {
        status,
        deviceType: deviceTypeString,
        deviceId,
        values,
        alerts,
      };

      return new Response(JSON.stringify(result), {
        headers: { "Content-Type": "application/json" },
      });
    } catch (error) {
      console.error(error);
      return new Response(JSON.stringify({ status: "error" }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  },
};

本番(エッジ)へデプロイする

ここまではローカルの wrangler dev での検証でしたが、実際にエッジへ公開しても同じように動きます。手順はシンプルです。

前提: wrangler deploy(エッジ公開)には Workers 有料プラン($5/月) が必要になる事があります。ローカル検証だけなら無料プランのままで構いません。

1. KV ネームスペースを作成して ID を設定する

ローカル検証では Wrangler が自動でローカル KV を用意してくれますが、本番では実体の KV ネームスペースが必要です。

npx wrangler kv namespace create PARSER_CACHE

出力される idwrangler.jsonc に貼り付けます。

"kv_namespaces": [
  { "binding": "PARSER_CACHE", "id": "ここに出力されたID" }
],

2. デプロイする

npx wrangler deploy

成功すると、バインディング(PARSER_CACHE / AI / PARSER_LOADER)が認識され、公開URLが表示されます。

Your Worker has access to the following bindings:
env.PARSER_CACHE   KV Namespace
env.AI             AI
env.PARSER_LOADER  Worker Loader     ← Dynamic Workers
...
  https://iot-edge-parser.<あなたのサブドメイン>.workers.dev

3. 公開URLで動作確認する

ローカルと同じ test.sh を、引数に公開URLを渡すだけでそのまま使えます。

bash test.sh https://iot-edge-parser.<あなたのサブドメイン>.workers.dev

筆者の環境でも、温湿度(25.22℃ / 55.20%)・CO2・閾値超過アラート・CRCエラー検出のすべてがローカルと同じ結果で返り、/register 経由の Workers AI による新規コード生成 → KVキャッシュ → Dynamic Worker 実行 の一連がエッジ上でも問題なく機能しました。

4. 後片付け(任意)

検証だけで公開を続ける必要がなければ、Worker を削除できます。

npx wrangler delete

削除後は公開URLが 404 を返すようになります(KVネームスペースは別途残るので、不要なら npx wrangler kv namespace delete --namespace-id で削除してください)。

コスト

この記事作成時点の金額です。最新金額はCloudflareにてご確認ください。

項目 料金
Workers 有料プラン $5/月(ローカル検証は無料プランでも可。エッジ公開時に必要になる事があります)
Dynamic Workers ロード課金 $0.002/ユニークWorker/日(ベータ期間中は無料
Workers AI 推論 1日10,000 Neurons まで無料、超過分は $0.011/1,000 Neurons の従量課金
KV 読み書き 無料枠あり(読み取り10万回/日、書き込み1,000回/日)

パーサーの登録は一度きりなのでWorkers AIのコストは初回のみ。ベータ期間中はDynamic Workersのロード課金が免除されているため、今が試し時です。

フィジカルAIで追加メリットがある"2つ目のエッジ"

エッジ(Cloudflare)で処理することで、低レイテンシと帯域削減できるメリットがあります。どちらもセンサー数が多いほど効いてきます。

フィジカルAIというと NVIDIA Jetson や Arm SoC といった「デバイス上のAI」が注目されがちですが、実際のシステムには デバイス(エッジ) → CDNエッジ → クラウド という階層があります。今回の構成はこのCDNエッジを知的な中間層として活用するという考え方で、デバイスとクラウドの間に処理層を挟むフォグコンピューティング的な考え方でもあります。その中間層を「CDNエッジ+AIが生成したコード」で実現するのが今回の案です。

すべてのゲートウェイにGPUを積めるわけではない現場でも、センサーが増え続ける状況で運用負荷を軽減できるはずです。「2つ目のエッジ」的な使い方で幅広く使えるのではないかなと感じています。


サンプルコード全文

iot-edge-parser/ というフォルダを作り、下記の構成どおりにファイルを配置すれば、npm installnpx wrangler dev でほぼそのまま動かせます。

iot-edge-parser/
├── wrangler.jsonc
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts
│   ├── types.ts
│   └── prompt.ts
└── test.sh

wrangler.jsonc は、npx wrangler kv namespace create PARSER_CACHE で出力される自分の ID に置き換えてください。

wrangler.jsonc

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "iot-edge-parser",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",
  "observability": {
    "enabled": true
  },

  // Dynamic Workers のローダーバインディング
  "worker_loaders": [
    { "binding": "PARSER_LOADER" }
  ],

  // KV: 生成済みパーサーコードのキャッシュ
  "kv_namespaces": [
    { "binding": "PARSER_CACHE", "id": "" }
  ],

  // Workers AI(LLMによるコード生成)
  "ai": { "binding": "AI" }
}

package.json

{
  "name": "iot-edge-parser",
  "version": "0.1.0",
  "private": true,
  "description": "IoTバイナリ電文をAIが解析 — Cloudflare Dynamic Workers でエッジパース",
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "test": "bash test.sh http://localhost:8787"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20260601.0",
    "typescript": "^5.8.0",
    "wrangler": "^4.0.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "strict": true,
    "noEmit": true,
    "types": ["@cloudflare/workers-types"]
  },
  "include": ["src"]
}

"lib": ["ES2022"] を明示しているのは、これを省くと既定で DOM 型が読み込まれ @cloudflare/workers-types と衝突して型チェックが大量にエラーになるためです。

src/types.ts

/** Wrangler バインディング */
export interface Env {
  PARSER_LOADER: WorkerLoader;
  PARSER_CACHE: KVNamespace;
  AI: Ai;
}

/** センサー登録リクエスト */
export interface RegisterRequest {
  /** プロトコル識別キー(例: "env-sensor-v2", "co2-monitor-mk3") */
  protocolId: string;
  /** 自然言語によるバイナリ電文仕様の記述 */
  spec: string;
  /** アラート閾値(オプション) */
  thresholds?: Record;
}

/** パース結果 */
export interface ParseResult {
  status: "ok" | "alert" | "error";
  protocolId: string;
  deviceType: string;
  deviceId: number;
  timestamp: string;
  values: Record;
  alerts?: Array<{ field: string; value: number; reason: string }>;
  error?: string;
}

/** KV に保存するパーサーエントリ */
export interface CachedParser {
  code: string;
  spec: string;
  thresholds?: Record;
  createdAt: string;
}

src/prompt.ts

本文「実際に動かして検証してわかったこと」で触れた、エンディアン・Response 返却・deviceType 文字列化の指示を盛り込んだ最終版です。

export function buildParserPrompt(
  spec: string,
  thresholds?: Record
): string {
  const thresholdSection = thresholds
    ? `
## アラート閾値
以下の閾値を超えた場合、alerts 配列に追加してください:
${JSON.stringify(thresholds, null, 2)}
`
    : "";

  return `あなたは IoT バイナリプロトコルのエキスパートです。
以下のバイナリ電文仕様に基づいて、JavaScript のパーサーコードを生成してください。

## バイナリ電文仕様
${spec}
${thresholdSection}

## 生成するコードの要件

1. 以下の形式の Worker エントリポイントを export default してください:

\`\`\`javascript
export default {
  async fetch(request) {
    // request.body からバイナリデータを読み取り
    const result = /* パース結果のオブジェクト */;
    // ★ 必ず Response オブジェクトを返すこと。素のオブジェクトを return してはいけない
    return new Response(JSON.stringify(result), {
      headers: { "Content-Type": "application/json" },
    });
  }
}
\`\`\`

2. request.body を ArrayBuffer として読み取り、DataView でパースしてください。
   **エンディアンに厳重に注意**: 仕様で "big-endian" と指定されたフィールドは、必ず DataView の第2引数を false にしてください(\`getInt16(offset, false)\` / \`getUint16(offset, false)\`)。第2引数を省略・true にすると little-endian になり誤った値になります。

3. レスポンスは以下の JSON 形式で返してください:
{
  "status": "ok" | "alert" | "error",
  "deviceType": "...",   // 数値コードではなく人間可読な文字列名(例: "temperature_humidity", "co2", "vibration")
  "deviceId": 数値,
  "values": { "フィールド名": 数値, ... },
  "alerts": [{ "field": "...", "value": 数値, "reason": "..." }]
}

4. CRC チェックサムの検証を実装してください。不一致の場合は status: "error" を返してください。

5. 閾値が指定されている場合、値が範囲外なら alerts に追加し status を "alert" にしてください。

6. エラーハンドリングを丁寧に行い、不正な電文でもクラッシュしないようにしてください。

## 制約
- Web 標準 API のみ使用可(DataView, Uint8Array, TextEncoder 等)
- Node.js の Buffer は使用不可
- fetch() や外部通信は使用不可(実行環境でブロックされます)
- import 文は使用不可(単一ファイルで完結)

コードのみを出力してください。説明やマークダウンの装飾は不要です。`;
}

src/index.ts

import type { Env, RegisterRequest, ParseResult, CachedParser } from "./types";
import { buildParserPrompt } from "./prompt";

export default {
  async fetch(request: Request, env: Env): Promise {
    const url = new URL(request.url);
    try {
      switch (`${request.method} ${url.pathname}`) {
        case "POST /register":
          return handleRegister(request, env);
        case "POST /parse":
          return handleParse(request, env);
        case "GET /protocols":
          return handleListProtocols(env);
        case "GET /health":
          return Response.json({ status: "ok", timestamp: new Date().toISOString() });
        default:
          return Response.json({ error: "Not Found" }, { status: 404 });
      }
    } catch (err) {
      const message = err instanceof Error ? err.message : "Unknown error";
      return Response.json({ error: message }, { status: 500 });
    }
  },
};

// POST /register — プロトコル登録(自然言語仕様 → Workers AI でパーサー生成 → KV キャッシュ)
async function handleRegister(request: Request, env: Env): Promise {
  const body = (await request.json()) as RegisterRequest;

  if (!body.protocolId || !body.spec) {
    return Response.json({ error: "protocolId と spec は必須です" }, { status: 400 });
  }

  // 既存チェック
  const existing = await env.PARSER_CACHE.get(`parser:${body.protocolId}`);
  if (existing) {
    return Response.json({
      message: "既に登録済みです。再生成するには先に DELETE してください。",
      protocolId: body.protocolId,
    });
  }

  // Workers AI でパーサーコードを生成
  const prompt = buildParserPrompt(body.spec, body.thresholds);
  const aiResponse = await env.AI.run("@cf/meta/llama-4-scout-17b-16e-instruct", {
    messages: [{ role: "user", content: prompt }],
    max_tokens: 2048,
  });

  const generatedCode = extractCode(aiResponse.response ?? "");
  if (!generatedCode) {
    return Response.json(
      { error: "コード生成に失敗しました。仕様を見直してください。" },
      { status: 500 }
    );
  }

  // KV にキャッシュ
  const entry: CachedParser = {
    code: generatedCode,
    spec: body.spec,
    thresholds: body.thresholds,
    createdAt: new Date().toISOString(),
  };
  await env.PARSER_CACHE.put(`parser:${body.protocolId}`, JSON.stringify(entry));

  return Response.json({
    message: "パーサーを生成・登録しました",
    protocolId: body.protocolId,
    codePreview: generatedCode.slice(0, 200) + "...",
  });
}

// POST /parse — バイナリ電文を Dynamic Worker でパース
async function handleParse(request: Request, env: Env): Promise {
  const protocolId = request.headers.get("X-Protocol-Id");
  if (!protocolId) {
    return Response.json({ error: "X-Protocol-Id ヘッダーが必要です" }, { status: 400 });
  }

  // KV からパーサーコードを取得
  const cached = await env.PARSER_CACHE.get(`parser:${protocolId}`);
  if (!cached) {
    return Response.json(
      { error: `プロトコル '${protocolId}' は未登録です。先に /register してください。` },
      { status: 404 }
    );
  }
  const entry = JSON.parse(cached) as CachedParser;

  // Dynamic Worker を生成・実行
  //   - get() は ID ベースでキャッシュ(同じ protocolId なら isolate を再利用)
  //   - globalOutbound: null でネットワーク完全遮断
  //   - バインディングを一切渡さないので KV/D1/R2 等にもアクセス不可
  const dynamicWorker = env.PARSER_LOADER.get(
    `parser-${protocolId}`,
    async () => ({
      compatibilityDate: "2026-06-01",
      mainModule: "parser.js",
      modules: { "parser.js": entry.code },
      globalOutbound: null, // ★ ネットワーク完全遮断
    })
  );

  // バイナリデータをそのまま Dynamic Worker に転送
  const binaryData = await request.arrayBuffer();
  const innerRequest = new Request("https://dummy/parse", {
    method: "POST",
    body: binaryData,
    headers: { "Content-Type": "application/octet-stream" },
  });

  const innerResponse = await dynamicWorker.getEntrypoint().fetch(innerRequest);
  const result = (await innerResponse.json()) as ParseResult;

  // 親 Worker 側でメタ情報を付与
  return Response.json({
    ...result,
    protocolId,
    timestamp: new Date().toISOString(),
  } satisfies ParseResult);
}

// GET /protocols — 登録済みプロトコル一覧
async function handleListProtocols(env: Env): Promise {
  const list = await env.PARSER_CACHE.list({ prefix: "parser:" });
  const protocols = list.keys.map((k) => ({
    protocolId: k.name.replace("parser:", ""),
  }));
  return Response.json({ protocols });
}

/** LLM レスポンスからコードブロックを抽出。フェンスなしの場合はそのまま返す */
function extractCode(raw: string): string | null {
  const fenced = raw.match(/```(?:javascript|js)?\s*\n([\s\S]*?)```/);
  if (fenced) return fenced[1].trim();
  if (raw.includes("export default")) return raw.trim();
  return null;
}

test.sh

#!/bin/bash
# IoT Edge Parser テストスクリプト
#   chmod +x test.sh && ./test.sh http://localhost:8787

BASE_URL="${1:-http://localhost:8787}"
echo "=== IoT Edge Parser テスト ==="
echo "Target: $BASE_URL"

# 1. プロトコル登録
echo "--- [1] プロトコル登録 ---"
curl -s -X POST "$BASE_URL/register" \
  -H "Content-Type: application/json" \
  -d '{
    "protocolId": "env-sensor-v1",
    "spec": "バイナリ電文仕様:\n  Byte 0: ヘッダ (固定値 0xAA)\n  Byte 1: デバイスタイプ (0x01=温湿度, 0x02=CO2, 0x03=振動)\n  Byte 2-3: デバイスID (uint16, big-endian)\n  --- 0x01(温湿度)--- Byte 4-5: 温度 (int16, big-endian, 0.01℃単位), Byte 6-7: 湿度 (uint16, big-endian, 0.01%単位)\n  --- 0x02(CO2)--- Byte 4-5: CO2濃度 (uint16, big-endian, ppm)\n  --- 0x03(振動)--- Byte 4-5: X軸加速度 (int16, big-endian, 0.001G単位), Byte 6-7: Y軸加速度 (int16, big-endian, 0.001G単位)\n  最終バイト: CRCチェックサム (全ペイロードのXOR)",
    "thresholds": {
      "temperature": { "min": -20, "max": 60 },
      "humidity": { "min": 0, "max": 100 },
      "co2_ppm": { "min": 0, "max": 2000 },
      "accel_x": { "min": -2.0, "max": 2.0 },
      "accel_y": { "min": -2.0, "max": 2.0 }
    }
  }' | jq .

# 2. 正常値テスト(温湿度: 25.22℃ / 55.20%)
echo "--- [2] 正常値テスト: 温湿度 25.22℃ / 55.20% ---"
printf '\xAA\x01\x00\x2A\x09\xDA\x15\x90\xD7' | \
  curl -s -X POST "$BASE_URL/parse" \
    -H "X-Protocol-Id: env-sensor-v1" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @- | jq .

# 3. アラートテスト(温度超過: 65.00℃)
echo "--- [3] アラートテスト: 温度 65.00℃(閾値超過)---"
printf '\xAA\x01\x00\x01\x19\x64\x13\x88\x4C' | \
  curl -s -X POST "$BASE_URL/parse" \
    -H "X-Protocol-Id: env-sensor-v1" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @- | jq .

# 4. CO2 センサーテスト(800 ppm — 正常)
echo "--- [4] CO2 センサー: 800 ppm(正常)---"
printf '\xAA\x02\x00\x0A\x03\x20\x81' | \
  curl -s -X POST "$BASE_URL/parse" \
    -H "X-Protocol-Id: env-sensor-v1" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @- | jq .

# 5. CRC エラーテスト
echo "--- [5] CRC エラーテスト(不正なチェックサム)---"
printf '\xAA\x01\x00\x2A\x09\xDA\x15\x90\xFF' | \
  curl -s -X POST "$BASE_URL/parse" \
    -H "X-Protocol-Id: env-sensor-v1" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @- | jq .

# 6. 登録済みプロトコル一覧
echo "--- [6] 登録済みプロトコル一覧 ---"
curl -s "$BASE_URL/protocols" | jq .

echo "=== テスト完了 ==="

jq は出力整形のためだけに使っています。未インストールの場合は各行の | jq . を省くか、| python3 -m json.tool に置き換えても確認できます。