👁️‍🗨️

ECMAScript Realms と ShadowRealm の提案

2021/09/20に公開
変更情報

【2024/01/25】

  • HTML の仕様と統合した際に ShadowRealm 内でどの API を使えるようにするか決めてから先に進めようということで Stage 2 へ降格したため、Stage についての表記を消しました。

【2021/09/01】

  • 提案されていた API のリネームが検討されていましたが ShadowRealm に決まりました。それに伴って記事の中身を修正しています。

https://github.com/tc39/proposal-shadowrealm/issues/321

https://github.com/babel/proposals/issues/76#issuecomment-909346623


Realms とは何か

Realms は JavaScript の言語仕様である ECMAScript で定義されている概念です。

https://262.ecma-international.org/11.0/#sec-code-realms

JavaScript のコードが評価されるグローバル変数や Array, Map といったビルトインクラス、そこで実行されるコードなどの状態やリソースのことを指します。

ShadowRealm API

現在 TC39 で ShadowRealm クラスが提案されています。これを使うことで新しい Realm を作って、JavaScript を評価させることが出来ます。

https://github.com/tc39/proposal-shadowrealm

Web Workers のように別のスレッドで動作されるわけではなく、同じスレッドで同期的に実行されます。

const realm = new ShadowRealm();

// 最後に評価された値を取得できる
const result = realm.evaluate(`
  function add(a, b) {
    return a + b;
  }
  add(2, 3);
`);
console.log(result); // 5
const realm = new ShadowRealm();

// 異なるグローバル環境を持つ
globalThis.foo = "foo";
realm.evaluate(`globalThis.foo = "bar";`);
console.log(foo); // "foo"
console.log(realm.evaluate(`foo`)); // "bar"

Web-based IDE やテスト、(非セキュアな)サンドボックス環境など様々な用途で使われることが想定されています。上記の例では文字列からコードを実行させていますが、提案によればモジュールを読み込むことも可能です[1]

歴史的背景

ShadowRealm は特段新しいものというわけではありません。そもそも ShadowRealm を使わないと新たに Realm が作れないというわけでもありません。

例えばブラウザでは DOM API の same-origin iframe を使うことで異なる Realm 上で JavaScript の評価を実現出来ます。

const iframe = document.createElement("iframe");

// sandbox 属性を付与して iframe を制限する
iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");

// DOM Tree に突っ込んだときに見えなくする
iframe.style.display = "none";

// DOM Tree に突っ込んで新たな Realm を作る
// contentWindow でグローバルオブジェクトにアクセスできるようになる
document.body.parentElement.appendChild(iframe);

// コードの実行
const result = iframe.contentWindow.eval(`
  function add(a, b) {
    return a + b;
  }
  add(2, 3);
`);
console.log(result); // 5

// 要らなくなったら DOM Tree から取り除く
iframe.remove();

このようなハックは古くからよく知られています。この提案の explainer によると特に Salesforce.com が広範囲に使用しているらしいです。

ブラウザだけではなく Node.js でも vm を使うことで実現できます。

const vm = require("vm");

const result = vm.runInNewContext(`
  function add(a, b) {
    return a + b;
  }
  add(2, 3);
`);
console.log(result); // 5

つまり ShadowRealm はどんな JavaScript の実行環境でも共通の書き方で、軽量に手軽に Realm を作ることが出来るインターフェースを提供することを目的としていると言えそうです。

ShadowRealm の制限

same-origin iframe, vm などを使った場合に、ビルトインクラスのオブジェクト判定に問題が起きる可能性があります。

実行しやすいように Node.js の vm を使った例で説明しますが、ブラウザでも同様です。

const vm = require("vm");

// Array と Map を受け取る
const { arr, map } = vm.runInNewContext(`
  const arr = [1, 2, 3];
  const map = new Map([["foo", "foo"], ["bar", "bar"]]);
  ({ arr, map });
`);

console.log(arr); // [1, 2, 3]
console.log(map); // Map(2) { 'foo' => 'foo', 'bar' => 'bar' }

// 他の Realm から受け取ったオブジェクトは
// その Realm 内のビルトインクラスから作られている
console.log(arr.constructor === Array); // false
console.log(map.constructor === Map); // false

// コンストラクタが違うのだからプロトタイプももちろん違う
console.log(arr instanceof Array); // false
console.log(map instanceof Map); // false

// Array には専用の函数があるのでこれで判定する
console.log(Array.isArray(arr)); // true

このように他の Realm からの結果をオブジェクトで受け取ったときに、それが何のオブジェクトなのか判定するのが非常に困難です[2]。これはライブラリを作っている立場からすると割と厄介な問題だったりします。

ShadowRealm では Realms 間のやり取りをプリミティブ値と Callable な函数[3]に制限することでそもそもオブジェクトのやりとりができないようになっています。これはセキュリティ的な理由も背景にあります。

基本的に多くの値のやり取りしたければ JSON 文字列化することになるのかなと思われます。

関連する提案 (2021/8/25 追記)

そして仮想化へ

完全に仮想化し、モジュールの読み込みをフックしたり、タイムゾーンを変えた状態で実行させたりと、より自由に制御する提案があります。Stage 1 Compartments です。

https://github.com/tc39/proposal-compartments

const compartment = new Compartment({
  // モジュール読み込みのフック
  async importHook(fullSpec) {
    // do something
  },

  // タイムゾーンのフック
  localTZAHook() {
    // do something
  },
});
const { ShadowRealm: VirtualizedRealm } = compartment.globalThis;
const realm = new VirtualizedRealm();

もっとセキュアに

異なる Realms ではビルトインクラスが異なるという話をしました。これは同じ参照を持っていた場合に、一方が polyfill などでプロトタイプを弄ることで他方がそれに影響されてしまうからです。

つまりプロトタイプオブジェクトを完全にフリーズしてしまえば同じビルトインクラスを使えるということです。それを叶えてくれるのが Stage 1 Secure ECMAScript(旧 Frozen Realms)です。

https://github.com/tc39/proposal-ses

// 現在の Realm 上の全てのビルトインクラスをフリーズ化
lockdown();

console.log(Object.isFrozen(Array.prototype)); // true
console.log(Object.isFrozen(Map.prototype)); // true

// この Realm では同じビルトインクラスが使われる
// (実際の提案とは異なるのであくまでイメージです)
const realm = new ShadowRealm();

もしかしたらこれによって ShadowRealm を使った場合でも、Realms 間でオブジェクトのやり取りができるようになる日がくるかもしれません。

結び

当初は ShadowRealm に制限があることを知らなかったので、「言語仕様に入ることで instanceof を使ったビルトインクラスのオブジェクト判定問題に遭遇する確率が高まる」と思いこの記事を書き始めましたが、そんなことはありませんでした。

ShadowRealm は JavaScript で遊ぶことが好きな自分にとっては面白い遊び道具になりそうです。はやく使えるようになって欲しい。

結構内容を端折った説明になっているかと思うので、興味があれば explainer を読んでみてください。

ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。

【余談】 ビルトインクラスのオブジェクト判定

オブジェクトの判定を行うライブラリでは、他の Realm からのオブジェクトがやってきたとしてもちゃんと扱えるように考えられています。

Array.isArray のように ECMAScript から提供されるビルトイン函数があればそれを使えばよいです。TypedArrayDataView なら ArrayBuffer.isView を利用することで少なくとも他のオブジェクトではないことがわかります[4]。しかし一般にビルトインクラスのオブジェクトを判定する函数は ECMAScript には定義されていません[5]

こういった場合は確実性に乏しいやり方ではありますが instanceof を使うのではなく Object#toString@@toStringTag[6] で判定するのが一般的かなと思います[7]lodash でもこの方法を採用しています。

例えば Map かどうか判定するのには以下のようにします。

Object#toStringを使った判定
function isMap(target) {
  return Object.prototype.toString.call(target) === "[object Map]";
}
@@toStringTagを使った判定
function isMap(target) {
  if (target === null || typeof target !== "object") {
    return false;
  }
  return target[Symbol.toStringTag] === "Map";
}

どっちもやっていることはほとんど変わりません。強いて言えば前者は古い環境でも動くくらいの違いです。

これらはともにオブジェクトの @@toStringTag の値を書き換えることで容易に偽装することが出来ますが、そのようなことは悪意がないと普通起きないため無視しています。一方で Cross-Realms なコードによって instanceof で判定に失敗することは自然に発生する可能性があるのでこちらを優先している感じですね。

いろんな環境で広く使われるコードを書こうと思ったら JavaScript は罠が多いので大変ですね……。

脚注
  1. JS Module Blocks の提案と相性が良さそうです。 ↩︎

  2. 実のところ Node.js には util.types があるので Map かどうか判定することが出来ます。ここでの趣旨は一般に ECMAScript の仕様で確実な判定方法が提供されていないということです。 ↩︎

  3. その返り値も同様に再帰的に制限されます。 ↩︎

  4. Node.js の util.types の実装を参考に %TypedArray%#[@@toStringTag] のゲッター函数を使って、どの TypedArray かを確実に判定する函数が作れたのでよかったら使ってみてください。 ↩︎

  5. 定義しようという提案もありましたが、もっといい方法があるだろうということで withdrawn されました。 ↩︎

  6. 全ての Realms で Well-known Symbols はシェアされると定義されているので、値の取得は問題なく行えます。 ↩︎

  7. もちろんハイブリッドに両方使って判定してもよいです。 ↩︎

Discussion