VNC 埋め込み API
Paprika worker のライブ画面を外部 Web ページに iframe で埋め込む実装ガイド
TL;DR #
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...
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 拡張):
| キー | 値 | 説明 |
|---|---|---|
path | sessions/<sid>/novnc/websockify | ★必須★ WS の forward 先パス。POST /jobs の応答に既に embed されている |
autoconnect | 1 | ページロード即接続。embed 用途では常に 1 |
resize | scale / remote / off | iframe サイズに対する画面の追従。scale は等比縮小、remote は worker 側 viewport を変更 |
reconnect | 1 | WS 切断時の自動再接続 |
view_only | true | キーボード / マウス入力を無効化 (= 監視専用) |
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_url | iframe 動作 | 推奨対処 |
|---|---|---|---|
queued | null | 埋められない | 「割り当て中…」プレースホルダ表示 |
running (lane 未 bind) | null | 埋められない | 同上、~5 秒間隔でポーリング |
running (lane bind 済) | 有 (/sessions/{sid}/novnc/?...) | live 表示 ✓ | そのまま埋め込み |
completed / failed / cancelled | 有 (URL 上の session は reaped 済み) | iframe 内に HTTP 404 が返る | iframe を DOM から外す or プレースホルダに差し替え |
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-Options も Content-Security-Policy: frame-ancestors も設定していないので、任意のオリジンから iframe で埋め込める。
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 の知識自体がトークン相当)、というアクセスモデル。
公開公開で配信する場合の選択肢:
- Reverse proxy で Basic 認証 ── 一番楽。nginx で
auth_basicを hub 全体にかける - セッショントークンをパスに挟む ──
/sessions/<sid>/novnc/<token>/形式に拡張、hub 側で issue + validate - 署名つき 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 Gateway | worker host 到達不可 (worker crash / network 断) | worker host の死活確認 → 自動 reschedule 待ち |
| 真っ黒画面 + status: "Disconnected" | WS が確立できていない (CSP / proxy / 認証問題) | DevTools の Network タブで wss:// upgrade の失敗理由を見る |
| "Failed to connect to server" loop | worker 側 websockify が応答していない | worker の docker logs paprika-worker-1 でエラー確認 |
13. パフォーマンス Tips #
- 非アクティブな iframe は
display: noneしない: vnc_lite.html は visibility 監視で接続を切ったりはしないが、レンダリング負荷は走り続ける。タブ切替のときは iframe.remove() で完全に外す方が GPU / CPU に優しい - 更新頻度は worker 側で決まる: hub はバイト透過しているだけで rate-limit していない。worker の x11vnc が変化検知ベースで送るので、静止画なら帯域はほぼゼロ
- 圧縮プロファイル: 帯域が厳しい環境では noVNC の
quality/compression設定を URL に追加。例:&quality=4&compression=6 - Hub からのレイテンシ: 同 LAN で ~10 ms、別 datacenter 経由 + WAN だと 50-100 ms。Web ベースの操作なので 200 ms までは違和感少ない
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)。トラブルシュート用。