OpenVessel

配信

このページの見出し
  1. 3 つのオリジン
  2. dist/ の中身
  3. ヘッダー
  4. キャッシュ
  5. ヘッダーを付けられないホスト(GitHub Pages)
  6. パスの下での配信
  7. セッションごとのプレビューのオリジン
  8. Service Worker の更新
  9. ブラウザの条件
  10. 設定例
  11. 確認の一覧

OpenVessel を本番に置く方法です。dist/ の中身、どのディレクトリをどのオリジンに置くか、各オリジンに必要な HTTP ヘッダーとその理由、キャッシュ、ヘッダーを付けられないホスト、パスの下での配信、セッションごとのプレビューのオリジン、サーバーの設定例をまとめます。

3 つのオリジン

OpenVessel は 3 つのオリジンで動きます。ブラウザから見ると、それぞれが別のサイトです。

オリジン置くもの読み込まれ方持つもの
ホスト埋め込む側のアプリ(dist/host/index.js はふつうアプリに同梱)一番上のページ画面とホスト API
ランタイムdist/runtime/OpenVessel.boot() が作る見えない iframe(runtime.html)カーネルの Worker・プロセスの Worker・通信の関所
プレビューdist/preview/見えない中継の iframe(__ov/relay.html)と、プレビューごとの iframe開発中のサイト(仮想ポートから配信)

../SECURITY.md にある分離が成り立つには、3 つを別のオリジンにする必要があります。ランタイムがホストと同じオリジンにあっても、ホストは警告(console.warn('OpenVessel: the runtime should be served from its own origin'))を出すだけで、起動は止めません。自分だけが使うデモなら 3 つを 1 つのオリジンに置けます(公開デモはそうしています。 GitHub Pages を参照)。自分で管理していないコードを動かすなら、そうしないでください。

import { OpenVessel } from 'openvessel';

const vessel = await OpenVessel.boot({
  runtimeUrl: 'https://runtime.example.com/',   // "/" で終わるディレクトリか、runtime.html の URL そのもの
  previewUrl: 'https://preview.example.com/',   // "/" で終わるディレクトリ
});

runtimeUrl は new URL('runtime.html', runtimeUrl)、previewUrl は new URL('./', previewUrl) で解決します(src/host/index.ts)。そのため、最後に / のない URL は親のディレクトリを指します。 https://example.com/runtime なら https://example.com/runtime.html を読みます。最後に / を付けたディレクトリか、 …/runtime.html の URL を渡してください。

dist/ の中身

npm run build(scripts/build.mjs)が dist/ を書き出します。静的なファイルとして配置するのは dist/runtime/ と dist/preview/ だけです。dist/host/ は、アプリが import する npm パッケージの入口です。

dist/runtime/(ランタイムのオリジン)

ファイル中身読み込まれるとき
runtime.htmlランタイムの iframe の文書。<meta name="openvessel-allowed-hosts" content="*"> を含むホストが OpenVessel.boot() を呼んだとき
runtime.jsruntime.html のふつうのスクリプト(IIFE)。オリジンの確認、Service Worker の登録、カーネルの起動、ホストへの通り道の受け渡し(src/runtime/runtime.ts)runtime.html と一緒に
sw.jsランタイムの Service Worker(src/runtime/sw.ts)。/__ov/esm/・/__ov/builtin/・/__ov/mw/ の ES モジュールの配信、通信の関所 /__ov/net、/__ov/ping、ランタイム自身のファイルへの分離のヘッダーruntime.js がスコープ ./ で登録
kernel.jsカーネル。ES モジュールの Worker(src/kernel/index.ts)起動ごとに 1 回、new Worker(…, { type: 'module' })
process.js旧ランタイム: 以前からの手書きの Node 互換の層(src/node/worker.ts)。プロセスごとに 1 つのモジュール Worker既定で、すべての node プロセス
node-process.js移植ランタイム: Node.js 自身の lib/ を、作り直した internalBinding() の上で動かす(src/nodejs/boot.ts)。プロセスごとに 1 つのモジュール WorkerVFS のファイル /etc/openvessel/node が port のとき、すべての node プロセス
nodelib.jsNode.js v22.23.4 の lib/(と deps/)のソースを文字列で持つもの。scripts/gen-nodelib.mjs が作る(約 4.5 MB)。ビルドのときに vendor/node/ があった場合だけ書き出されるnode-process.js が静的に import
ts-strip.jssucrase(ふつうのスクリプト)。移植ランタイムが require(esm) の ESM → CommonJS の書き換えに使う移植ランタイムで初めて使うとき。同期の XMLHttpRequest で取って評価
amaro.jsNode 自身の amaro(swc を WebAssembly にしたもの。JavaScript に埋め込み、約 3.4 MB)。Node が同梱するのと同じ版移植ランタイムで初めて TypeScript の型を取り除くとき。同期の XMLHttpRequest
chunks/chunk-<hash>.jskernel.js と process.js が共有するコード(esbuild の分割)その 2 つが静的に import
chunks/esm-<hash>.js分割した sucraseカーネルか旧ランタイムが初めて TypeScript を取り除く、または ESM を書き換えるときに動的 import()
chunks/legacy-<hash>.js@noble/hashes の古いハッシュ組み込みの md5sum が動的 import()
compat/index.json互換パッケージの一覧。パッケージ名 → { range, file, integrity, pkg }npm のインストーラ(src/kernel/npm/install.ts)が 1 回だけ取る
compat/<id>-<version>.tgzネイティブのバイナリしか出していないパッケージの代わりの互換パッケージ(npm の tarball)。このビルドでは pagefind-1.5.2.tgz・cn-font-split-7.4.3.tgz・resvg-js-2.6.2.tgznpm install がそのパッケージを置き換えるとき。完全性(sha512)を確かめる
NODE-LICENSE.txtNode.js のライセンスと、同梱する依存の告知(nodelib.js と一緒に配る)読み込まれない。配置からは外さない
AMARO-LICENSE.txtamaro と swc のライセンス(amaro.js と一緒に配る)読み込まれない。配置からは外さない
*.mapソースマップ(sourcemap: 'linked')開発者ツールだけ。なくてもよい

チャンクのハッシュはビルドごとに変わります。名前の一覧ではなく、ディレクトリをまるごと配置してください。

node のプロセスにどちらのスクリプトを使うかは、プロセスを起動するたびに決まります。カーネルは /etc/openvessel/node を読み、前後の空白を除いた中身が port なら node-process.js、そうでなければ process.js を起動します(src/kernel/index.ts の processScript())。公開している互換性の数字は移植ランタイムのものです。移植ランタイムを使うには、プロセスを起動する前にこのファイルを書きます。

await vessel.mount({ node: 'port' }, { path: '/etc/openvessel' });

カーネルは、最後に起動したスクリプトの Worker を 2 つ待機させておきます(WARM = 2)。最初の node のあとは、読み込み済みの Worker から始まります。

dist/preview/(プレビューのオリジン)

ファイル中身
__ov-sw.jsプレビューの Service Worker(src/preview/sw.ts)。プレビューのディレクトリの一番上にあるので、既定のスコープがプレビュー全体になる
__ov/relay.html見えない中継の iframe の文書
__ov/relay.js中継のふつうのスクリプト。../__ov-sw.js をスコープ ../ で登録し、カーネルへの通り道を渡す(src/preview/relay.ts)
__ov/inject.jsプレビューが表示するすべての HTML に差し込む。WebSocket のトンネルと、パスの下で配信するときの fetch/XMLHttpRequest のパスの補正(src/preview/inject.ts)
*.mapソースマップ。なくてもよい

プレビューのオリジンのそれ以外の URL は、すべて Service Worker が仮想ポートから答えます。そのため、プレビューするサイトは(プレビューのディレクトリから見て)__ov-sw.js・__ov/relay.html・__ov/relay.js・__ov/inject.js・ __ov/open のパスを使えません。プレビュー を参照してください。

dist/host/ と dist/types/

ファイル中身
dist/host/index.jsホスト API の ES モジュール(package.json の main/exports)。アプリから import する。ふつうはバンドルする
dist/types/host/index.d.ts・dist/types/shared/policy.d.tsTypeScript の型宣言(package.json の types)

dist/playground/ は npm run serve が出す開発用の playground で、配置には含めません。

ヘッダー

まとめ

ヘッダーホストのページランタイム(dist/runtime/)プレビュー(dist/preview/)
Cross-Origin-Opener-Policysame-origin(必須)same-originsame-origin
Cross-Origin-Embedder-Policyrequire-corp か credentialless(必須)require-corprequire-corp
Cross-Origin-Resource-Policyアプリに合わせて(scripts/serve.mjs は same-origin)cross-origincross-origin
Content-Security-Policyアプリのもの下のランタイムの CSP を、sw.js を除くすべてのファイルに要らない
Content-Security-Policy: frame-ancestors—ホストのオリジン—
Service-Worker-Allowed—要らない要らない

scripts/serve.mjs が使うヘッダーの組と同じです(serve.mjs はプレビューに Service-Worker-Allowed: / も付けますが、コードはこれを必要としません)。serve.mjs はエラーの応答にも同じヘッダーを付けます。

それぞれのヘッダーが要る理由

cross-origin isolation(COOP + COEP)。 システムコールは SharedArrayBuffer の上で Atomics.wait を使って待ちます(通信の形式)。SharedArrayBuffer は cross-origin isolated な環境にしかありません。

  • ホストのページは isolated である必要があります。crossOriginIsolated が false だと、OpenVessel.boot() は OpenVessel: the host page must be cross-origin isolated (COOP: same-origin, COEP: require-corp or credentialless). を投げます。
  • ランタイムの文書も isolated である必要があります。runtime.js は何よりも先に crossOriginIsolated を確かめ、そうでなければ OpenVessel needs a cross-origin isolated page (COOP: same-origin and COEP: require-corp/credentialless on the host page, and allow="cross-origin-isolated" on the iframe). で失敗します。ホストはランタイムの iframe に allow="cross-origin-isolated" を付けて作ります。
  • COEP のページに埋め込む文書は、それ自身にも COEP が要り、別オリジンなら Cross-Origin-Resource-Policy: cross-origin も要ります。runtime.html とプレビューの文書の両方に COEP と CORP cross-origin を付けるのはこのためです。
  • カーネルとプロセスの Worker は、ランタイムのオリジンから読むモジュール Worker です。ほかのランタイムのファイルと同じヘッダーで配信します。

ランタイムの CSP。 ランタイムの中のコード(プロジェクトとその依存)が届く先を絞ります。scripts/serve.mjs は次を送ります。

default-src 'self';
script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob: data:;
connect-src 'self' data: blob:;
worker-src 'self' blob:;
img-src 'self' data: blob:;
style-src 'self' 'unsafe-inline';
frame-ancestors <ホストのオリジン>
ディレクティブコードが必要とする理由
script-src 'unsafe-eval'Node の組み込みモジュールは、nodelib.js から間接 eval でコンパイルする(src/nodejs/boot.ts の compileBuiltin())。vm・CommonJS の包み・amaro.js・ts-strip.js も同じように評価する
script-src 'wasm-unsafe-eval'esbuild・rollup・rolldown・lightningcss・amaro(swc)・互換パッケージは WebAssembly で動く
script-src data:移植ランタイムのモジュールローダーは data:text/javascript,… のモジュールを import する(src/nodejs/bindings/module_wrap.ts)。旧ランタイムも eval で ES モジュールを動かす worker_threads で使う
connect-src 'self'この CSP の目的。プロセスは fetch や WebSocket でランタイムのオリジンの外へ出られないので、出口は /__ov/net だけになる。そこで Service Worker が通信の方針を確かめる
frame-ancestorsruntime.html を埋め込めるのをホストのページだけにする

sw.js には CSP を付けません。 Service Worker 自身の fetch() は、Worker のスクリプトと一緒に届いた CSP に従います。ランタイムの Service Worker は通信の関所で、方針が許したあと本物の外への要求をします。sw.js に connect-src 'self' があると、許したはずの要求がすべて失敗します。scripts/serve.mjs は /sw.js だけ CSP を外します。

frame-ancestors とランタイム自身のオリジンの確認。 ランタイムは、誰が自分を埋め込んでいるかを 3 つの方法で確かめます。

確認場所失敗したとき
CSP の frame-ancestorsサーバーブラウザが iframe を表示しない。boot() は時間切れになる
runtime.html の <meta name="openvessel-allowed-hosts">。オリジンを空白かカンマで区切る。* はどこでも(配布時の既定)runtime.js が openvessel-connect のメッセージの origin と比べるorigin <origin> is not allowed to use this runtime
location.ancestorOrigins(ブラウザにあれば)が 2 つ以上でないことruntime.jsthe runtime must be embedded directly by the host page

ホストではどれも boot() の失敗になります。下の 2 つは OpenVessel: <メッセージ>、1 つ目は(timeout、既定 30000 ms のあと)OpenVessel: runtime did not start in time です。配置する runtime.html の meta を書き換えてください。

<meta name="openvessel-allowed-hosts" content="https://app.example.com https://staging.example.com">

プレビューのオリジン。 プレビューの静的なファイルには、上の COOP/COEP/CORP が要ります。プレビューが表示するものはすべて Service Worker から来ます。Service Worker は、各応答に cross-origin-embedder-policy: credentialless・ cross-origin-resource-policy: cross-origin・cross-origin-opener-policy: same-origin を(まだ無ければ)付けます。そのため開発サーバーがこれらを送る必要はありません。credentialless なので、CORP を送らない外の画像やスクリプトも読めます。プレビューのオリジンにランタイムの CSP は付けないでください。プレビューは Service Worker で独自に方針を確かめます(プレビュー)。

Service-Worker-Allowed は要りません。ランタイムは sw.js をスコープ ./(自分のディレクトリ)で、中継は ../__ov-sw.js をスコープ ../(プレビューのディレクトリ。Worker のスクリプトがあるディレクトリでもある)で登録します。

MIME タイプ

拡張子タイプ注意
.htmltext/html; charset=utf-8
.jstext/javascript; charset=utf-8必須。JavaScript 以外のタイプだと、モジュール Worker・import・Service Worker のスクリプトは拒まれる
.jsonapplication/jsoncompat/index.json・ソースマップ
.mapapplication/json
.tgzapplication/gzip か application/octet-streamfetch().arrayBuffer() で読む。タイプは確かめない
.txttext/plain; charset=utf-8ライセンスのファイル

dist/ に .wasm のファイルはありません。WebAssembly は JavaScript に埋め込まれているか(amaro.js)、互換パッケージの tarball の中にあるか、npm からメモリ上のファイルシステムに入れてバイト列からコンパイルします。自分でファイルを足すなら、 .wasm は application/wasm で配信してください。

キャッシュ

ファイルの名前の付け方から決まります。

ファイルおすすめ理由
runtime/chunks/*-<hash>.js長く、immutable中身が変わると名前も変わる
runtime/compat/<id>-<version>.tgz長く名前に版がある。どのみち index.json の integrity で確かめる
runtime/sw.js・preview/__ov-sw.jsno-cacheService Worker の更新が届く必要がある。ランタイムは updateViaCache: 'none' で登録する
ほかのすべて(runtime.html・runtime.js・kernel.js・process.js・node-process.js・nodelib.js・amaro.js・ts-strip.js・compat/index.json・__ov/*)no-cache(ETag/Last-Modified で確かめ直す)名前は固定で、中身はリリースごとに変わる。kernel.js と process.js は同じビルドのハッシュ付きチャンクを名前で指す

scripts/serve.mjs はすべてに cache-control: no-cache を付けます。Service Worker が自分で作る応答はキャッシュされません。 /__ov/esm/・/__ov/builtin/・/__ov/mw/ のモジュールと /__ov/net の応答には cache-control: no-store が付きます。

npm のクライアントは、確かめた tarball をランタイムのオリジンの Cache Storage(キャッシュ名 openvessel-npm-v1)に置き、読み戻すときにも完全性を確かめ直します。これはブラウザごと、ランタイムのオリジンごとです。

ヘッダーを付けられないホスト(GitHub Pages)

2 つの Service Worker は、自分が配信するファイルに分離のヘッダーを付けます。サーバーが送ったヘッダーはそのまま残します。

Service Worker付ける対象無いときに付けるヘッダー
ランタイムの sw.jsスコープの中の同じオリジンへの GET のうち、/__ov/… の窓口でないものすべて(opaque とステータス 0 の応答はそのまま)cross-origin-opener-policy: same-origin・cross-origin-embedder-policy: require-corp・cross-origin-resource-policy: cross-origin
プレビューの __ov-sw.js自分のファイル(__ov/relay.html・__ov/relay.js・__ov/inject.js・__ov-sw.js)、__ov/open のリダイレクト、仮想ポートからのすべての応答cross-origin-embedder-policy: credentialless・cross-origin-resource-policy: cross-origin・cross-origin-opener-policy: same-origin

Service Worker が制御するのは、インストールされたあとに読み込んだ文書だけです。runtime.html と __ov/relay.html の最初の読み込みは、これらの Worker の制御の外です。そして runtime.js は、isolated でなければ(sw.js を登録する前に)起動をやめます。そのため、ヘッダーをまったく送らないホストでは、最初の読み込みを isolated にするものが別に必要です。デモ(openvessel-demo)は次のようにしています。

  • ページ・runtime/・preview/ を 1 つのオリジン(GitHub Pages)の、1 つのサイトのディレクトリに置く。runtime/ は dist/runtime の、preview/ は dist/preview のコピー。
  • ページはサイトの一番上から自分の coi-sw.js を登録する。そのスコープ(サイトのディレクトリ)は runtime/ と preview/ も含むので、それぞれの Worker が入るまでは、coi-sw.js が同じオリジンへのすべての GET(runtime.html と relay.html を含む)に cross-origin-opener-policy: same-origin・cross-origin-embedder-policy: require-corp・ cross-origin-resource-policy: cross-origin を付ける。登録が済むと、ランタイムとプレビューの Worker が、より狭い自分のスコープを引き継ぐ。
  • ページは coi-sw.js を登録したあと 1 回だけ読み直す(sessionStorage の印で 1 回に限る)。それでも isolated にならなければ「対応していない」と表示する。
<script>
  // GitHub Pages sends no isolation headers: let coi-sw.js add them, then reload once
  if (!crossOriginIsolated && 'serviceWorker' in navigator && !sessionStorage.getItem('ov-coi-reload')) {
    navigator.serviceWorker.register('coi-sw.js').then(() => navigator.serviceWorker.ready).then(() => {
      sessionStorage.setItem('ov-coi-reload', '1');
      location.reload();
    });
  } else sessionStorage.removeItem('ov-coi-reload');
</script>
// coi-sw.js
const ISOLATION = {
  'cross-origin-opener-policy': 'same-origin',
  'cross-origin-embedder-policy': 'require-corp',
  'cross-origin-resource-policy': 'cross-origin',
};
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
self.addEventListener('fetch', (e) => {
  const req = e.request;
  if (req.method !== 'GET' || new URL(req.url).origin !== location.origin) return;
  e.respondWith(fetch(req).then((res) => {
    if (res.type === 'opaque' || res.status === 0) return res;
    const headers = new Headers(res.headers);
    for (const [k, v] of Object.entries(ISOLATION)) if (!headers.has(k)) headers.set(k, v);
    return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
  }));
});

デモはページからの相対 URL で起動し、プロセスを移植ランタイムに切り替えます。

const here = new URL('./', location.href);
const vessel = await OpenVessel.boot({
  runtimeUrl: new URL('runtime/runtime.html', here).href,
  previewUrl: new URL('preview/', here).href,
  network: { mode: 'allowlist', presets: ['npm', 'fonts', 'cdn'] },
});
await vessel.mount({ node: 'port' }, { path: '/etc/openvessel' });

デモの build.mjs は dist/runtime を site/runtime に、dist/preview を site/preview にコピーし、空の .nojekyll (GitHub Pages がすべてのファイルをそのまま配信するように)を置きます。deploy.mjs は site/ を gh-pages の枝に force-push します。ヘッダーが無いので、ランタイムの CSP も frame-ancestors もありません。この形では、外への connect-src の絞り込みと、埋め込みの制限をあきらめることになります。1 つのオリジンなので、オリジンの分離もあきらめます。デモだけに使ってください。

パスの下での配信

ランタイムもプレビューも、サブディレクトリから配信できます(例: https://example.com/demo/runtime/ と https://example.com/demo/preview/)。

ランタイム。 カーネルは自分の基準の URL を kernel.js のディレクトリから(src/kernel/base.ts の RUNTIME_BASE)、移植ランタイムは node-process.js のディレクトリから取ります。Service Worker は要求のパスからスコープを取り除いてから /__ov/… と照らし合わせます。npm のクライアントの /__ov/net と compat/ の取得、移植ランタイムの /__ov/net と /__ov/mw/ の URL は、どれもこの基準からの相対です。旧ランタイムは /__ov/esm/・/__ov/builtin/・/__ov/net の URL をオリジンから作ります(src/node/worker.ts の self.location.origin)。そのため旧ランタイムでは、ランタイムのディレクトリをオリジンの一番上に置く必要があります。

プレビュー。 Service Worker のスコープはプレビューのディレクトリです。OpenVessel の中の開発サーバーは、サイトをあくまで / で配信するので、Service Worker が両者を対応させます。

  • 要求のパスからスコープを取り除いてから仮想ポートに渡す
  • HTML のページでは、ルートからの相対の src・href・action・poster・formaction・srcset の値の前にスコープを付ける。差し込むスクリプトと一緒に import map {"imports": {"/": "<scope>", "<scope>": "<scope>"}} を入れ、ルートからのモジュール指定子(/@vite/client・/src/…)がスコープの下に解決されるようにする
  • ルートからの相対の Location ヘッダーも同じように書き換える
  • inject.js が fetch() と XMLHttpRequest.prototype.open を包み、ルートからの相対の文字列の URL にもスコープを付ける

ほかの方法で作った URL(たとえば new URL('/x', location.href) を fetch に渡す、CSS の url(/…))は書き換えません。プレビューを専用のオリジンの一番上で配信すれば、これらはすべて不要です。

セッションごとのプレビューのオリジン

多人数で使うときは、セッション(利用者・ワークスペース)ごとにプレビューのオリジンを分けます。たとえば https://<session>.preview.example.com/ です(../SECURITY.md)。サブドメインが違えばオリジンも違うので、あるセッションのプレビューのページは、ほかのセッションのクッキー・ストレージ・Service Worker の状態を読めません。

  • すべてのサブドメインで同じ dist/preview/ を配信する(ワイルドカードの DNS のレコードと証明書、ワイルドカードの server_name を持つ 1 つの server ブロック)
  • 起動のときにセッションの URL を previewUrl に渡す。ホストはそこからプレビューのオリジンを求め、中継のメッセージはそのオリジンのその iframe からだけ受け取る
  • 各サブドメインは、初めて使うときにプレビューの Service Worker を自分で登録する
  • ランタイムのオリジンは共有できる。ランタイムは boot() ごとに作られ、プレビューへの通り道はホストが自分でカーネルに渡すので、カーネルはプレビューのオリジンを知る必要がない
const vessel = await OpenVessel.boot({
  runtimeUrl: 'https://runtime.example.com/',
  previewUrl: `https://${sessionId}.preview.example.com/`,
});

Service Worker の更新

ランタイムの sw.jsプレビューの __ov-sw.js
installskipWaiting()skipWaiting()
activateclients.claim()clients.claim()
登録register('./sw.js', { scope: './', updateViaCache: 'none' }) のあと reg.update()中継が register('../__ov-sw.js', { scope: '../' })
起動のときWorker が installing か waiting のあいだ、runtime.js は controllerchange を待つ。古い Worker のままカーネルを起動しない中継は navigator.serviceWorker.ready を待つ
動いている途中の更新controllerchange で、runtime.js が新しい Worker にカーネルへの新しい通り道を渡す新しい Worker は、初めて必要になったときに通り道を求める

どちらの Worker も、カーネルへの通り道をメモリに持ちます。ブラウザが暇な Worker を止めて、また起動すると、Worker は新しい通り道を求めます(need-kernel-port)。ランタイムの Worker は runtime.html に、プレビューの Worker は中継に求め、中継はホストに求めます。要求は通り道を最大 15 秒待ちます。プレビューの Worker がメモリに持つ状態(どのプレビューのページがどのポートのものか、許可したホスト)は、こうして起動し直すと空に戻ります。通信の形式 を参照してください。

ブラウザの条件

OpenVessel は Chromium 系のブラウザで開発・テストしています(エンドツーエンドのテストは Chrome を動かします)。コードが頼っているのは次のものです。

  • cross-origin isolation、SharedArrayBuffer、Worker の中の Atomics.wait
  • モジュール Worker(new Worker(url, { type: 'module' }))と、カーネルの Worker から作る入れ子の Worker
  • ランタイムとプレビューのオリジンの、別オリジンの iframe の中の Service Worker
  • iframe の allow="cross-origin-isolated"
  • Worker の中の同期の XMLHttpRequest(移植ランタイム: amaro.js・ts-strip.js)
  • 組み込みの許可のダイアログのための <dialog>(onPermissionRequest を渡さないときだけ)

location.ancestorOrigins はあれば使います。

設定例

上のヘッダーの組をそのまま送る設定です。ホスト名は置き換えてください。app.example.com がホストのページです。

nginx

nginx では、自分の add_header を持つ location の中で、server の階層の add_header が効かなくなります。そのため共通のヘッダーは snippet にして、必要な所で include します。

# /etc/nginx/snippets/openvessel-isolation.conf
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;

# /etc/nginx/snippets/openvessel-runtime-csp.conf
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob: data:; connect-src 'self' data: blob:; worker-src 'self' blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; frame-ancestors https://app.example.com" always;
# runtime origin
server {
    listen 443 ssl;
    server_name runtime.example.com;
    root /srv/openvessel/dist/runtime;

    location / {
        include snippets/openvessel-isolation.conf;
        include snippets/openvessel-runtime-csp.conf;
        add_header Cache-Control "no-cache" always;
    }
    location = /sw.js {
        # the network gate: no CSP here
        include snippets/openvessel-isolation.conf;
        add_header Cache-Control "no-cache" always;
    }
    location /chunks/ {
        include snippets/openvessel-isolation.conf;
        include snippets/openvessel-runtime-csp.conf;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }
}

# preview origin (one block for every session subdomain)
server {
    listen 443 ssl;
    server_name preview.example.com *.preview.example.com;
    root /srv/openvessel/dist/preview;

    location / {
        include snippets/openvessel-isolation.conf;
        add_header Cache-Control "no-cache" always;
    }
}

# host page
server {
    listen 443 ssl;
    server_name app.example.com;
    root /srv/app;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
}

nginx の既定の mime.types は .js を application/javascript にします。ブラウザはこれも JavaScript として受け付けます。

Caddy

runtime.example.com {
    root * /srv/openvessel/dist/runtime
    header {
        Cross-Origin-Opener-Policy "same-origin"
        Cross-Origin-Embedder-Policy "require-corp"
        Cross-Origin-Resource-Policy "cross-origin"
    }
    @notSw not path /sw.js
    header @notSw Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob: data:; connect-src 'self' data: blob:; worker-src 'self' blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; frame-ancestors https://app.example.com"
    @chunks path /chunks/*
    header @chunks Cache-Control "public, max-age=31536000, immutable"
    @other not path /chunks/*
    header @other Cache-Control "no-cache"
    file_server
}

preview.example.com, *.preview.example.com {
    root * /srv/openvessel/dist/preview
    header {
        Cross-Origin-Opener-Policy "same-origin"
        Cross-Origin-Embedder-Policy "require-corp"
        Cross-Origin-Resource-Policy "cross-origin"
        Cache-Control "no-cache"
    }
    file_server
}

app.example.com {
    root * /srv/app
    header {
        Cross-Origin-Opener-Policy "same-origin"
        Cross-Origin-Embedder-Policy "require-corp"
    }
    file_server
}

(*.preview.example.com のワイルドカードの証明書には、Caddy の DNS チャレンジが要ります。)

Node.js(scripts/serve.mjs)

リポジトリの開発用サーバーが、ヘッダーの組の手本です。dist/playground(ホスト)・dist/runtime・dist/preview を 3 つのポート、OV_PORT_BASE(既定 5170)とその次の 2 つで配信します。OV_HOST_PORT・OV_RUNTIME_PORT・ OV_PREVIEW_PORT でそれぞれを変えられます。待ち受けは 127.0.0.1 だけで、frame-ancestors は http://localhost:<ホストのポート> です。

確認の一覧

  • ホスト・ランタイム・プレビューが 3 つの別のオリジンになっている(多人数で 1 つの配置を使うなら、プレビューのオリジンがセッションごとになっている)
  • ホストのページが Cross-Origin-Opener-Policy: same-origin と Cross-Origin-Embedder-Policy: require-corp (か credentialless)を送り、crossOriginIsolated が true になる
  • dist/runtime/ と dist/preview/ のすべてのファイルが、COOP same-origin・COEP require-corp・CORP cross-origin を送る
  • sw.js を除くランタイムのすべてのファイルがランタイムの CSP を送り、frame-ancestors にホストのオリジンがある
  • runtime.html の openvessel-allowed-hosts が * ではなくホストのオリジンになっている
  • .js を JavaScript の MIME タイプで配信している
  • dist/runtime/ をまるごと配置している(chunks/・compat/・nodelib.js・amaro.js・ts-strip.js・ライセンスのファイルを含む)
  • runtimeUrl と previewUrl が / で終わっている(か、runtimeUrl が runtime.html の URL)
  • ハッシュ付きのチャンクは長くキャッシュしてよい。ほか、特に Service Worker は確かめ直す
  • プロセスを移植ランタイムで動かすなら、/etc/openvessel/node が port になっている
  • ランタイムをパスの下で配信するなら、プロセスは移植ランタイムを使う

この文書の元は、リポジトリの docs/reference/deployment.ja.md です(英語版は deployment.md)。