Paprika — VNC 埋め込み API

マニュアル 自動配信 GitHub

VNC 埋め込み API

Paprika worker のライブ画面を外部 Web ページに iframe で埋め込む実装ガイド

関連: マニュアル · Worker 自動配信 · 関連 commit 584fcb7 (hub-side noVNC proxy)

TL;DR #

3 行で: 1) POST /jobs で帰ってくる novnc_url をそのまま iframe の src に放り込む。
2) URL は https://<hub>/sessions/<session_id>/novnc/?... ── hub 経由なので worker LAN IP は隠れる。
3) ジョブ終了で iframe が黒くなる前に postMessage 等で自前で外す。

1. URL の組み立て #

ジョブ作成のレスポンスから novnc_url を取得する:

$ curl -s -X POST https://paprika.example.com/jobs \
       -H 'Content-Type: application/json' \
       -d '{"url": "https://example.com",
            "options": {"mode": "fetch", "wait_seconds": 30}}'

{
  "job_id": "a3b4c5d6e7f8",
  "status": "queued",
  "url": "https://example.com",
  "novnc_url": null,                        <-- まだ lane に bind 前
  "worker_id": null,
  "lane_idx": null,
  ...
}

数秒後 GET /jobs/{id} でポーリングすると lane が割り当てられ、hub-proxy URL が返ってくる:

$ curl -s https://paprika.example.com/jobs/a3b4c5d6e7f8 | jq .novnc_url

"/sessions/ses_abc123def456/novnc/?path=sessions/ses_abc123def456/novnc/websockify&autoconnect=1&resize=scale&reconnect=1"

これは hub からの相対パス。同一オリジンで埋めるならそのまま使える。別オリジンに埋めるなら hub の絶対 URL を前置:

const HUB = 'https://paprika.example.com';
const fullUrl = HUB + novnc_url;
// -> https://paprika.example.com/sessions/ses_abc123def456/novnc/?path=jobs/...&autoconnect=1...
注意: hub が HTTPS 配下にあるとき、viewer は自動で wss:// を使う (vnc_lite.html の window.location.protocol 判定)。HTTP / HTTPS 混在ブラウザ警告を避けたい場合は埋め込み元ページと hub の TLS を揃える。

2. 最小 iframe 例 #

<iframe
  src="https://paprika.example.com/sessions/ses_abc123def456/novnc/?path=sessions/ses_abc123def456/novnc/websockify&autoconnect=1&resize=scale&reconnect=1"
  width="1024" height="640"
  style="border: 1px solid #444; background: #000;"
  allow="clipboard-read; clipboard-write"
  title="paprika worker live view">
</iframe>

これだけでブラウザに worker の Chrome が出る。マウス / キーボードも届く (operator が触れる)。読み取り専用にしたければ ?view_only=true を追加。

3. クエリパラメータ #

vnc_lite.html が解釈する query (noVNC 上流仕様 + path のみ paprika 拡張):

キー説明
pathsessions/<sid>/novnc/websockify★必須★ WS の forward 先パス。POST /jobs の応答に既に embed されている
autoconnect1ページロード即接続。embed 用途では常に 1
resizescale / remote / offiframe サイズに対する画面の追従。scale は等比縮小、remote は worker 側 viewport を変更
reconnect1WS 切断時の自動再接続
view_onlytrueキーボード / マウス入力を無効化 (= 監視専用)
password(string)RFB password。worker 側で設定していなければ無視
host / port(string / int)WS 接続先のホスト / ポート上書き。**通常は touch しない** (window.location から自動)

監視専用 (view_only) 例

<iframe
  src="https://paprika.example.com/sessions/<sid>/novnc/?path=sessions/<sid>/novnc/websockify&autoconnect=1&resize=scale&reconnect=1&view_only=true"
  width="800" height="500" style="border:none;">
</iframe>

operator がうっかり画面を触ってもクリック / 入力が伝わらない。BI ダッシュボード組み込みに最適。

4. ライフサイクル #

session_id は secrets.token_urlsafe(16) = 128 bit ランダムなので global かつ恒久的に一意。一度発行された ID は再発行されない。session が reap された後その ID で叩くと永遠に 404 が返る (= URL が "切れた" ことを冪等に検知できる)。

ジョブには 4 つの状態がある。embed 側でハンドルすべき遷移:

ジョブ状態novnc_urliframe 動作推奨対処
queuednull埋められない「割り当て中…」プレースホルダ表示
running (lane 未 bind)null埋められない同上、~5 秒間隔でポーリング
running (lane bind 済)有 (/sessions/{sid}/novnc/?...)live 表示 ✓そのまま埋め込み
completed / failed / cancelled有 (URL 上の session は reaped 済み)iframe 内に HTTP 404 が返るiframe を DOM から外す or プレースホルダに差し替え
404 を踏むと: iframe 内に hub の素朴な JSON エラー画面が出る。見栄えが悪いので、親側で GET /jobs/{id} を 2-5 秒間隔でポーリングし、terminal 状態を見つけたら iframe を unmount するのが standard pattern (§5)。session-rooted は「session が有る = URL が live」「session が無い = 404」のシンプル 2 値で扱える。

5. JavaScript 制御 #

素の JS で job 開始 → ライフサイクル監視 → iframe 出し入れする最小例:

const HUB = 'https://paprika.example.com';

async function embedPaprikaJob(targetUrl, mountEl) {
  // 1) ジョブ投入
  const submit = await fetch(`${HUB}/jobs`, {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      url: targetUrl,
      options: { mode: 'fetch', wait_seconds: 30 },
    }),
  });
  const job = await submit.json();
  const jobId = job.job_id;

  // 2) Loading プレースホルダ
  mountEl.innerHTML =
    `<div style="padding:20px;color:#888;">割り当て中... (${jobId})</div>`;

  // 3) novnc_url が立ち上がるまでポーリング (5s 間隔)
  let info = job;
  while (!info.novnc_url) {
    await new Promise(r => setTimeout(r, 5000));
    info = await (await fetch(`${HUB}/jobs/${jobId}`)).json();
    if (['completed','failed','cancelled'].includes(info.status)) {
      mountEl.innerHTML = `<div style="color:#c00;">早期終了: ${info.status}</div>`;
      return;
    }
  }

  // 4) iframe を作って差し込む
  const iframe = document.createElement('iframe');
  iframe.src = HUB + info.novnc_url;
  iframe.style.cssText = 'width:100%; height:100%; border:none; background:#000;';
  iframe.title = `paprika ${jobId}`;
  mountEl.replaceChildren(iframe);

  // 5) 終了監視 — terminal になったら iframe を外す
  const watcher = setInterval(async () => {
    const r = await fetch(`${HUB}/jobs/${jobId}`);
    if (!r.ok) return;
    const cur = await r.json();
    if (['completed','failed','cancelled'].includes(cur.status)) {
      clearInterval(watcher);
      iframe.remove();
      mountEl.innerHTML =
        `<div style="color:#888;">ジョブ終了 (${cur.status})</div>`;
    }
  }, 5000);
}

// 使い方
embedPaprikaJob('https://example.com',
                document.getElementById('vnc-host'));

6. React コンポーネント例 #

import { useEffect, useState } from 'react';

const HUB = process.env.NEXT_PUBLIC_PAPRIKA_HUB || 'https://paprika.example.com';

export function PaprikaVNC({ jobId, viewOnly = false }) {
  const [info, setInfo] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let alive = true;
    const tick = async () => {
      try {
        const r = await fetch(`${HUB}/jobs/${jobId}`);
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        const j = await r.json();
        if (!alive) return;
        setInfo(j);
        if (['completed','failed','cancelled'].includes(j.status)) {
          return; // stop polling
        }
        setTimeout(tick, 3000);
      } catch (e) {
        if (alive) setError(e.message);
      }
    };
    tick();
    return () => { alive = false; };
  }, [jobId]);

  if (error)               return <div className="vnc-err">⚠ {error}</div>;
  if (!info)               return <div className="vnc-loading">…</div>;
  if (!info.novnc_url)     return <div className="vnc-pending">割り当て中…</div>;
  if (['completed','failed','cancelled'].includes(info.status)) {
    return <div className="vnc-done">ジョブ終了 ({info.status})</div>;
  }

  let src = HUB + info.novnc_url;
  if (viewOnly) src += '&view_only=true';

  return (
    <iframe
      src={src}
      title={`paprika ${jobId}`}
      style={{ width: '100%', height: '100%', border: 'none', background: '#000' }}
      allow="clipboard-read; clipboard-write"
    />
  );
}

7. 複数同時埋め込み #

1 ページに 4 つの worker 画面を並べるダッシュボード例:

<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; height: 600px;">
  <iframe src="https://paprika.example.com/sessions/ses_aaaa/novnc/?path=sessions/ses_aaaa/novnc/websockify&autoconnect=1&resize=scale&reconnect=1&view_only=true"></iframe>
  <iframe src="https://paprika.example.com/sessions/ses_aaab/novnc/?path=sessions/ses_aaab/novnc/websockify&autoconnect=1&resize=scale&reconnect=1&view_only=true"></iframe>
  <iframe src="https://paprika.example.com/sessions/ses_aaac/novnc/?path=sessions/ses_aaac/novnc/websockify&autoconnect=1&resize=scale&reconnect=1&view_only=true"></iframe>
  <iframe src="https://paprika.example.com/sessions/ses_aaad/novnc/?path=sessions/ses_aaad/novnc/websockify&autoconnect=1&resize=scale&reconnect=1&view_only=true"></iframe>
</div>

同時 10 viewer 接続は smoke test 済。同時 100+ は hub のメモリ / CPU に応じて要評価。各 iframe = 1 WebSocket + 1 HTTP keepalive、いずれも hub にプール。

8. サイズ / アスペクト比 #

worker の Chrome 解像度は 1920×1080 がデフォルト。resize=scale 指定時 iframe のサイズに 等比縮小で fit する (余白は背景色で埋まる)。

iframe を 16:9 で固定したい場合:

<div style="position: relative; padding-top: 56.25%;">
  <iframe src="..."
          style="position:absolute; top:0; left:0; width:100%; height:100%; border:none; background:#000;">
  </iframe>
</div>

worker 側の解像度を変えたい場合は resize=remote を指定。iframe サイズが server-side viewport にそのまま反映される (Chrome の表示領域が変わる)。fetch ジョブの再現性に影響するので、scraping 用途では推奨しない。

9. CSP / X-Frame-Options #

現状 hub / worker のいずれも X-Frame-OptionsContent-Security-Policy: frame-ancestors も設定していないので、任意のオリジンから iframe で埋め込める

本番運用 (公開 SaaS 等) では: 信頼する埋め込み元ドメインに絞る frame-ancestors を hub の reverse proxy で付与することを推奨。
add_header Content-Security-Policy "frame-ancestors 'self' https://customer.example.com;";

埋め込み元 CSP との衝突

埋め込み元ページ自身が Content-Security-Policy: frame-src を厳格化していると iframe ロードがブロックされる。hub オリジンを許可する:

Content-Security-Policy: frame-src https://paprika.example.com;
                         connect-src https://paprika.example.com wss://paprika.example.com;

connect-src も忘れずに ── iframe 内の noVNC viewer が WebSocket を hub に張る。これが CSP で禁止されると iframe は無音で死ぬ。

10. クロスオリジン #

iframe 自体は same-origin policy の対象外 (リソースとして読み込む)。ただし JS API で POST /jobs を叩く部分は CORS 制約を受ける。hub 側で CORSMiddleware を有効化する:

# server/hub/app.py
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://customer.example.com"],
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)

あるいは埋め込み元と hub の間に reverse proxy を入れて同一オリジンに見せる方が安全。

11. 認証 / トークン #

現行 hub は LAN 信頼前提で、/sessions/<sid>/novnc/* エンドポイントには認証なし。session_id を知っている = 該当セッションを見れる (session_id は 128bit ランダムなので URL の知識自体がトークン相当)、というアクセスモデル。

公開公開で配信する場合の選択肢:

  1. Reverse proxy で Basic 認証 ── 一番楽。nginx で auth_basic を hub 全体にかける
  2. セッショントークンをパスに挟む ── /sessions/<sid>/novnc/<token>/ 形式に拡張、hub 側で issue + validate
  3. 署名つき URL (HMAC) ── ?sig=<hmac>&exp=<ts> をクエリに付与、hub middleware で検証 (RFB の動作には影響しないので推奨)

v1 の範囲外。実装するなら別 endpoint hook で middleware として挿入。

12. エラーハンドリング #

iframe 内で起きる代表的なエラー:

表示原因対処
HTTP 404 (session not found)session_id が存在しない / 既に reap 済み (ジョブが terminal 状態 / idle TTL 5分超過 / hub 再起動)親側で GET /jobs/<id> で current session_id を再取得、または iframe を unmount
HTTP 502 Bad Gatewayworker host 到達不可 (worker crash / network 断)worker host の死活確認 → 自動 reschedule 待ち
真っ黒画面 + status: "Disconnected"WS が確立できていない (CSP / proxy / 認証問題)DevTools の Network タブで wss:// upgrade の失敗理由を見る
"Failed to connect to server" loopworker 側 websockify が応答していないworker の docker logs paprika-worker-1 でエラー確認

13. パフォーマンス Tips #

14. FAQ #

Q. iframe 内のページを右クリック保存できる?

noVNC は VNC RFB プロトコルの中継。iframe 内に映っているのは「画像」(VNC framebuffer) であって DOM ではない。ブラウザのコンテキストメニューは無効。スクリーンショット保存は親ページから POST /jobs/{id}/screenshot/capture を叩くのが正攻法。

Q. iframe を表示したまま親ページからクリックを送れる?

同一オリジンなら iframe 内の noVNC API (rfb.sendKey(), rfb.sendCtrlAltDel() 等) に直アクセス可能。クロスオリジンなら postMessage で iframe 内に届けるためのブリッジを vnc_lite.html 改造で追加する必要がある (要 hub ビルド)。v1 ではサポート外

Q. iPad / モバイルでも動く?

動く。noVNC は touch event を VNC pointer event に翻訳する。が、worker の Chrome は desktop 表示なのでタップ範囲がきつい。resize=scale 必須。

Q. 同時にどれくらい繋げる?

1 worker lane あたり同時 viewer 数の上限なし (websockify 仕様)。だが 1 lane に複数 viewer は同じ画面を見るだけ (RFB は単一 framebuffer の共有閲覧)。Worker は最大 25 台 × 2 lane = 50 lane なので、ユニークな画面の上限は 50。Hub 側のスループット上限は別途要評価 (~100 同時 viewer まで実績あり)。

Q. ジョブ終了後にちらっと表示が残るのを綺麗に消したい

iframe を `display:none` ではなく `iframe.remove()` で確実に DOM から外す。worker 側 lane は次の job が来たら別画面で上書きされるので、消さないと「前のジョブの最後の状態」がうっすら残る。

Q. デバッグで生 noVNC URL を見たい

API は hub-proxy URL に書き換えて返すが、内部の worker-direct URL は/workers から取れる:

curl -s https://paprika.example.com/workers | jq '.workers[] | {worker_id, address, lane_novnc_urls}'

運用時はこれを社外に出さない (= 元々隠したかった LAN IP)。トラブルシュート用。