プレビュー
このページの見出し
OpenVessel の中で動くサーバーを iframe に表示するしくみです。中継の iframe とプレビューの Service Worker、 __ov/open の URL、HTTP の要求・応答と WebSocket を仮想ポートまで運んで戻す方法、差し込むスクリプトの役目、プレビューにできることとできないことをまとめます。メッセージの形は 通信の形式 に、ヘッダーと配信は 配信 にあります。
全体像
host page preview origin runtime origin
───────── ────────────── ──────────────
OpenVessel ─ creates ─▶ relay iframe (__ov/relay.html)
│ registers __ov-sw.js, gives it a MessagePort ──────▶ kernel worker
│ NetRouter
└ creates ─▶ preview iframe ──fetch──▶ __ov-sw.js ──{k:'req'…}──────────▶ ├ ported runtime: TcpBridge
(sandboxed) (service worker) │ HTTP/1.1 bytes on a socketpair
inject.js ──ws-connect──▶ └ legacy runtime: http-req messages
| 部品 | ソース | 役目 |
|---|---|---|
| 中継の iframe | src/preview/relay.html・src/preview/relay.ts | 見えない。プレビューのオリジンにある。プレビューの Service Worker を登録し、ホストが作ったカーネルへの通り道を渡す |
| プレビューの Service Worker | src/preview/sw.ts(dist/preview/__ov-sw.js) | プレビューのページのすべての要求に仮想ポートから答える。WebSocket をトンネルする。HTML に inject.js を差し込む。ほかのオリジンへの要求に通信の方針を当てる |
| 差し込むスクリプト | src/preview/inject.ts(dist/preview/__ov/inject.js) | ローカルの URL への window.WebSocket を置き換える。パスの下で配信するときは、ルートからの fetch/XHR の URL を補正する |
| カーネルの側 | src/kernel/index.ts(attachPreview)・src/kernel/net.ts・src/kernel/tcp.ts | ポートで待ち受けているプロセスへ要求を回す |
| ホストの側 | src/host/index.ts(previewUrl・createPreview) | 中継とプレビューの iframe を作り、中継をカーネルにつなぐ |
手元のマシンには何も開きません。ポートはカーネルの表の項目で、ブラウザのページからそこへ届く道は、この Service Worker だけです。
プレビューの始まり
vessel.previewUrl(port, path?) と vessel.createPreview(port, opts?) には、OpenVessel.boot() の previewUrl が要ります。無いときは OpenVessel: previewUrl was not configured で reject します。
最初の呼び出しで中継を作ります(2 回目からは使い回します)。
- ホストが見えない iframe
<previewUrl>__ov/relay.htmlを足す(aria-hidden・tabIndex = -1・大きさ 0・visibility:hidden)。 relay.jsが../__ov-sw.jsをスコープ../で登録し、navigator.serviceWorker.readyを待って、親にopenvessel-preview-loadedを送る。- ホストが
MessageChannelを作り、片方をカーネルに(カーネルへの通り道で{ t: 'preview-port', port })、もう片方を中継に(defaultPort付きのopenvessel-preview-connect)送る。 - 中継がそのポートを動いている Service Worker に渡し(
kernel-port)、openvessel-preview-readyを送る。ホストの promise が解決する。
20000 ms のうちに中継の準備ができなければ、OpenVessel: preview relay did not start で reject します。2 回目からの previewUrl()・createPreview() は、そのポートを openvessel-preview-default-port で送り、既定のポートにします。
__ov/open の URL
<previewUrl>__ov/open?port=<port>&path=<url-encoded path>
| パラメーター | 意味 | 既定 |
|---|---|---|
port | このページが属する仮想ポート | 既定のポート(無い・0・数でないとき) |
path | サーバーで開くパスとクエリ | / |
Service Worker は、プレビューのオリジンの path への 302 で答えます(パスの下で配信するときはスコープを前に付けます。配信 を参照)。このリダイレクトには分離のヘッダーが付いています。ただの Response.redirect() は、COEP のホストのページの中では拒まれるためです。Service Worker はさらに、この移動で生まれるページ(resultingClientId)に port を記録します。そのページの要求は、すべてそのポートへ行きます。
const url = await vessel.previewUrl(4321, '/blog/?draft=1');
// https://preview.example.com/__ov/open?port=4321&path=%2Fblog%2F%3Fdraft%3D1
server-ready のイベントは、/ を開く同じ形の URL を url に持ちます(previewUrl が無ければ null)。この URL は中継を待たずに作るので、中継がつながっていることを確かめたいときは previewUrl() か createPreview() を使ってください。
createPreview()
createPreview(port, { path, container }) は previewUrl(port, path) を待ってから、新しい iframe を返します。
| 属性 | 値 |
|---|---|
sandbox | allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads allow-pointer-lock(allow-top-navigation は無い。プレビューはホストのページを移動させられない) |
allow | clipboard-write; fullscreen |
referrerPolicy | no-referrer |
style | width:100%;height:100%;border:0;background:#fff |
container を渡せば、その中に足します。allow-same-origin は、iframe を本物のプレビューのオリジンに置くためのものです。 Service Worker がその iframe を制御するのに必要です。
要求がどのポートへ行くか
| 要求 | ポート |
|---|---|
__ov/open への移動 | URL の port(生まれるページに記録する) |
プレビューの iframe の中のほかの移動(リンク・フォーム・location の変更・読み直し) | 移動を始めたページのポート。無ければ既定のポート |
サブリソース(fetch・XHR・スクリプト・画像など) | 要求したページに記録したポート。無ければ既定のポート |
| ローカルの URL への WebSocket | 要求したページのポート。無ければ URL のポート。それも無ければ既定のポート |
ポート 4000 で開いた iframe の中で移動しても、ポート 4000 のままです。別々のポートのプレビューを同時に開いておけます。既定のポートは、いちばん最近の previewUrl()・createPreview() の port です。
ポートの記録・既定のポート・許可したホストの控えは、Service Worker のメモリにあります。ブラウザが暇な Service Worker を起動し直すと、中継は新しいカーネルへの通り道と一緒に既定のポートを渡し直します。ただしページごとの記録は空に戻るので、開いたままのページの要求は、そのあと既定のポートへ行きます。
HTTP の要求
制御しているページの同じオリジンへの要求は、すべて Service Worker が扱います。例外は自分のファイル(__ov/relay.html・ __ov/relay.js・__ov/inject.js・__ov-sw.js。サーバーから取り、分離のヘッダーを足す)と __ov/open です。
要求
| 部分 | 仮想ポートへ送るもの |
|---|---|
| メソッド | そのまま |
| URL | パスとクエリ(フラグメントは送らない)。パスの下で配信するときはスコープを取り除く |
| ヘッダー | 要求のヘッダーから sec-*・cookie・accept-encoding を除き、host: localhost:<port> を足したもの。Referer は渡らない |
| 本文 | GET と HEAD 以外のすべてのメソッド。全部を読んで(req.arrayBuffer())1 つのメッセージで送り、終わりの印を送る。要求の本文はストリームしない |
accept-encoding を除くので、サーバーは圧縮せずに答えます。
カーネルの側
カーネルは待ち受けの表でポートを引きます(src/kernel/net.ts の NetRouter.request)。
| 待ち受け | 作るもの | 届け方 | server-ready・ports() に出るか |
|---|---|---|---|
raw | 移植ランタイム: すべての TCP サーバー(net.Server・http.Server など) | TcpBridge: 要求ごとに socketpair の新しい接続を作り、HTTP/1.1 のバイト列で | 出る |
http(記録のホストは待ち受けのアドレスか空) | 旧ランタイムの http.Server | プロセスへの http-req/http-res のメッセージ(通信の形式) | 出る |
tcp-only | 旧ランタイムの net.Server | 届かない: connect ECONNREFUSED 127.0.0.1:<port> | 出ない |
| 無い、またはプロセスが動いていない | — | connect ECONNREFUSED 127.0.0.1:<port> | — |
listen(0) は 49152〜65151 の空いたポートを選びます。表にすでにあるポートは EADDRINUSE になります。待ち受けは、そのプロセスが終わるか、exec でほかのプログラムになるか、サーバーを閉じると消え、ホストに port-closed が届きます。プロセスのあいだで共有した待ち受けのソケット(cluster のように IPC で渡したもの)は、接続を順番に(ラウンドロビンで)受け取ります。
移植ランタイム: ソケットの上の HTTP/1.1(src/kernel/tcp.ts)
待ち受けているプロセスに新しい接続(tcp-accept)が届きます。相手のアドレスは 127.0.0.1、相手のポートは 40000 から 60000 まで数え上げて一周します。カーネルが要求の頭を書きます。
- 要求の行
<METHOD> <url> HTTP/1.1 - 上のヘッダーから
connection・keep-alive・transfer-encoding・upgrade・sec-websocket-key・sec-websocket-versionを除いたもの。Hostが無ければHost: localhost:<port>を先頭に Connection: close(要求ごとに 1 接続)。本文があってcontent-lengthが無いときはTransfer-Encoding: chunked(本文を chunk で送り、0\r\n\r\nで終える)
応答は HTTP/1.1 として読みます。101 以外の 1xx は読み飛ばします。本文は content-length、chunked(trailer は捨てる)、または接続が閉じるまで読みます。長さの無い 204 と 304 には本文がありません。先へ渡すヘッダーからは transfer-encoding・connection・keep-alive を除きます。エラーは、頭が揃う前に接続が閉じれば socket hang up、長さや chunk で区切った本文の途中で閉じれば incomplete response です。
旧ランタイム
カーネルは待ち受けているスレッドに http-req、続いて http-body・http-body-end を送り、http-res・http-res-data・ http-res-end・http-res-error を送り返します。もう扱っていないポートへの要求を受けたプロセスは、本文 no server の 503 Service Unavailable で答えます。要求の途中でプロセスが終わると、要求は server process exited で失敗します。
応答
| 部分 | ページが受け取るもの |
|---|---|
| ステータス | 送られたまま。ただし 101 は 200 にする。ステータスの文もそのまま |
| ヘッダー | 送られたまま。ただし content-length・transfer-encoding・connection・keep-alive・content-encoding を除く。パスの下で配信するときは location をスコープの下に書き換える。cross-origin-embedder-policy: credentialless・cross-origin-resource-policy: cross-origin・cross-origin-opener-policy: same-origin を、無ければ足す。ブラウザが受け付けない値のヘッダーは黙って落とす。Set-Cookie はページに届かない(Service Worker はこれを持つ応答を作れない) |
| 本文 | ストリームする。サーバーからの塊を、届いたそばから渡す。204・205・304・HEAD には本文が無い |
| HTML への移動 | 要求が移動で、content-type に text/html を含み、本文があるときは、本文全体を文字列として(UTF-8 で)読み、スクリプトを差し込み、ひとまとめで返す |
ページが本文を取り消すと(たとえば iframe がほかへ移動すると)、Service Worker は abort を送ります。カーネルは接続を閉じる(移植ランタイム)か、http-abort を送ります(旧ランタイム)。
ブラウザのメモリのほかに、要求と応答の大きさの上限はありません。HTML への移動と要求の本文を除き、応答は溜めません。
リダイレクト
サーバーの 3xx は、そのままページに返します。Service Worker がリダイレクトをたどることはありません。ブラウザがいつもどおりたどります(redirect: 'manual' の fetch() には opaqueredirect が見えます)。ルートからの相対の Location はプレビューのオリジンに留まります。http://localhost:4321/x のような絶対の Location は別のオリジンを指すので、外への要求として扱います(下 を参照)。
エラー
| 場面 | 結果 |
|---|---|
| ポートで誰も待ち受けていない、本文の無い要求 | 200 ms ごとに最大 15 回(約 3 秒)やり直してから、エラーのページ。Vite のような開発サーバーは、ポートを確かめるために一瞬だけ待ち受け、少しあとで本当に待ち受けるため |
| 誰も待ち受けていない、本文のある要求 | すぐにエラーのページ |
応答の頭より前のエラー(移植ランタイムの socket hang up、旧ランタイムの server process exited) | すぐにエラーのページ |
| 頭のあとのエラー | 応答の本文のストリームが失敗する |
| Service Worker にカーネルへの通り道が無く、15 秒のうちに得られない | 要求はネットワークのエラーで失敗する(preview is not connected to OpenVessel) |
エラーのページは、content-type: text/html; charset=utf-8 と分離のヘッダーを持つ 502 です。要求の種類によらず返します。
Nothing is listening on port 4999
connect ECONNREFUSED 127.0.0.1:4999
Start a dev server (for example npm run dev) and reload.
WebSocket
Service Worker は WebSocket を横取りできません。そこで inject.js が、プレビューが配信するすべての HTML で window.WebSocket を置き換えます。URL のホストがページ自身のホストか、ホスト名が localhost・127.0.0.1・[::1] なら(ポートも ws:/wss: も問わず)トンネルします。それ以外の URL は、ブラウザ自身の WebSocket を使います。
- ページは、新しい
MessagePortと一緒に{ t: 'ws-connect', port, path, protocols, vport, origin }を、自分を制御する Service Worker に送る(vportは、ホスト名がページと違うときの URL のポート。同じなら 0)。 - Service Worker はポートを選び(ページのポート、無ければ
vport、無ければ既定のポート)、カーネルに開くよう頼む(ws-open)。ヘッダーはsec-websocket-protocol(求めたプロトコルを,でつないだもの)・host: localhost:<port>・origin(プレビューのオリジン)で、空のものは省く。 - 移植ランタイム: カーネルは
Connection: Upgrade・Upgrade: websocket・Sec-WebSocket-Version: 13と乱数のSec-WebSocket-Keyを付けた HTTP/1.1 の upgrade の要求を送る。101の応答で、サーバーが選んだSec-WebSocket-Protocolのソケットが開く。旧ランタイム:upgrade: trueのhttp-reqを送り、ws-openで答えが来る。 - フレームはメッセージで運ぶ。テキストは文字列、バイナリは
Uint8Array。移植ランタイムでは、カーネルは自分が送るフレームをマスクし、ping には pong で答え、close のフレームから閉じるコードを読む(無ければ1005)。
ページのポートが先に来るので、ポート 4000 で開いたページから ws://localhost:<別のポート>/ へのソケットも、ポート 4000 へ行きます。URL のポートを使うのは、ポートを持たないページで、しかも既定のポートが無いときだけです。
トンネルする WebSocket のオブジェクト
| メンバー | ふるまい |
|---|---|
url | 絶対の URL |
readyState | 開くまで 0、開いたら 1、close() のあと 2、閉じたら 3 |
protocol | サーバーが選んだプロトコル。開く前は '' |
extensions | いつも '' |
bufferedAmount | いつも 0 |
binaryType | 'blob'(既定)か 'arraybuffer'。バイナリのメッセージは Blob か ArrayBuffer で届く |
send(data) | 文字列はテキストのフレーム、ArrayBuffer・ビュー・Blob はバイナリ(Blob は読み終えてから送る)。開いていなければ InvalidStateError(WebSocket is not open)を投げる |
close(code = 1000, reason = '') | close を送り、サーバーを待たずにすぐ close を知らせる |
| イベント | open・message(origin は URL のオリジン)・error・close。on… のプロパティと addEventListener の両方 |
error | 開かないまま閉じたとき、またはコードが 1006 のとき、close の前に出る |
close のイベント | code・reason・wasClean(1000 のときだけ true) |
| 静的な定数 | CONNECTING・OPEN・CLOSING・CLOSED |
制御する Service Worker が無ければ、ソケットはコード 1006、理由 no service worker で閉じます。カーネルがつなげない(待ち受けが無い)ときは、1006 とエラーのメッセージを理由にして閉じます。サーバーとの接続が close のフレーム無しに終わったときも 1006 です。サーバーが upgrade に 101 ではなくふつうの HTTP の応答を返すと、ソケットは開きも閉じもしません。
window.WebSocket.prototype はブラウザのままですが、トンネルしたソケットは instanceof WebSocket になりません。
差し込むスクリプト
Service Worker は、HTML への移動のすべての応答に次を差し込みます。
<script src="<scope>__ov/inject.js"></script>。パスの下で配信するときは、その前に import map- 場所は最初の
<head …>のすぐあと。無ければ<!doctype …>のすぐあと。それも無ければ先頭
inject.js(ページごとに 1 回だけ動くよう守っています)がすること:
| すること | いつ |
|---|---|
window.WebSocket を、ローカルの URL をトンネルするコンストラクタに置き換える(上) | いつも |
window.fetch と XMLHttpRequest.prototype.open を包み、/ 1 つで始まり、まだスコープの下にない文字列の URL の前にスコープを付ける | パスの下で配信するときだけ |
それ以外のことはしません。エラーの表示も、ホストへのメッセージも、ランタイムへの手出しもありません。
外への要求と通信の方針
プレビューのページからほかのオリジンへの要求(http: と https: だけ。ほかのスキームはブラウザに任せる)は、 Service Worker が送る前に、ホストの通信の方針(通信の方針)と照らし合わせます。
- そのホスト(ホスト名とポート)を、この Service Worker が動いているあいだに許したことがあれば、すぐに送る。許可は覚えておく。拒否は次にまた尋ねる。
- そうでなければ、Service Worker がカーネルに尋ねる(
net)。カーネルは方針で決め、askのモードならinitiator: 'preview'・source: 'preview'のpermission-requestを出す(組み込みのダイアログは「プレビューのページが次のサイトへ接続しようとしています。」と表示する)。同じホスト名への問い合わせはまとめる。答えの無い問い合わせは 5 分で拒否になる。 - 許されたら、Service Worker が元の要求を
fetchする。拒まれたら、ステータスの文Blocked by OpenVessel network policyと空の本文の403。
Service Worker がカーネルへの通り道を得られないとき(15 秒のうちに無いとき)は、確認を飛ばして要求を送ります。
気をつける点:
localhost・127.0.0.1などへの絶対の URL は、仮想ポートではなく別のオリジンです。プレビューからhttp://localhost:4321/へのfetchは外への要求で、方針を通ります(許されれば、本物のマシンへ行きます)。開発サーバーに届けるには、ルートからの相対の URL を使ってください。- トンネルしないホストへの WebSocket は、ブラウザの
WebSocketを使います。Service Worker からは見えないので、方針の確認を受けません。
クッキーとストレージ
- ひとつの配置のプレビューは、ポートによらずプレビューのオリジンを共有します。あるサイトが書いたクッキー・
localStorage・IndexedDB・キャッシュは、ほかのプレビューからも見え、ふつうのサイトと同じようにブラウザに残ります。利用者を分けるには、セッションごとのプレビューのオリジンを使ってください(配信)。 - 要求の
Cookieのヘッダーはサーバーへ渡らず、サーバーのSet-Cookieもブラウザに届きません。document.cookieはプレビューのオリジンで使えますが、そのクッキーはサーバーには見えません。
プレビューがランタイムに頼めること・頼めないこと
カーネルのプレビュー用の通り道が受け付けるメッセージは、req・body・body-end・abort・ws-open・ws-msg・ ws-close・net だけです(通信の形式)。これでプレビューにできるのは、仮想ポートへの HTTP の要求と WebSocket の接続、それに外の URL が許されるかを尋ねること(ホストに許可の確認が出ることがある)です。ファイルの読み書き、プロセスの起動やシグナル、通信の方針の変更、許可の確認への回答はできません。これらのメソッドはホストの通り道にしかありません。中継の iframe もプレビューのオリジンで動くので、それを乗っ取っても同じ通り道しか得られません。
プレビューの Service Worker が kernel-port と default-port を受け付けるのは、URL のパスが /relay.html で終わるクライアントからだけです。ws-connect は、制御しているどのページからでも受け付けます。
関係するソースファイル
| ファイル | 中身 |
|---|---|
src/preview/relay.ts・src/preview/relay.html | 中継のフレーム |
src/preview/sw.ts | プレビューの Service Worker |
src/preview/inject.ts | 差し込むスクリプト |
src/kernel/index.ts | attachPreview・checkNetwork |
src/kernel/net.ts | NetRouter: ポート → プロセス、旧ランタイムのメッセージの橋渡し |
src/kernel/tcp.ts | TcpBridge: HTTP のメッセージ ⇄ HTTP/1.1 のバイト列、WebSocket のフレーム |
src/host/index.ts | previewUrl・createPreview・中継の準備 |
test/e2e/preview-port.mjs | エンドツーエンドのテスト: 移植ランタイムのサーバーにプレビューから届く |
この文書の元は、リポジトリの docs/reference/preview.ja.md です(英語版は preview.md)。