Skip to content
🔒
@tuvix.js/sandbox
サンドボックス
再利用可能なShadow DOMルートによるCSS分離、そのルートへのスタイルシートの取り込み、スクリプトのグローバル変数を実際のwindowに書き込ませないProxy windowを提供します。

インストール ​

bash
npm install @tuvix.js/sandbox
# or pnpm add @tuvix.js/sandbox

使いどころ ​

マイクロアプリのCSSとページのCSSが互いに影響してはならない場合はCssSandboxを使います。アプリのDOMをコンテナー上のopen shadow rootに移動するため、アプリのセレクターは内側に、ページのセレクターは外側にとどまります。@tuvix.js/coreのオーケストレーターは、sandbox: trueでアプリを登録するとこれを自動で行います。オーケストレーターを使わずにアプリをマウントする場合は、このパッケージを直接呼び出してください。

サードパーティのスクリプトテキストを実行し、そのグローバルへの書き込みを実際のwindowから切り離したい場合はJsSandboxを使います。コードはexecScriptで実行し、window、this、自由識別子はProxyを通じて解決されます。オーケストレーターはこれを適用しません。

信頼できないコードを強固に分離する必要がある場合は、別の手段を選んでください。sandbox属性付きの<iframe>はコードに独自のグローバルオブジェクトとオリジンを与えますが、このパッケージはそうしません。

クイックスタート ​

オーケストレーター経由(CSSのみ):

ts
import { createOrchestrator } from '@tuvix.js/core';

const orchestrator = createOrchestrator();

orchestrator.register({
  name: 'cart',
  entry: { scripts: ['/apps/cart/main.js'], styles: ['/apps/cart/style.css'] },
  container: '#cart',
  activeWhen: '/cart/*',
  sandbox: true,
});

await orchestrator.start();

オーケストレーターは#cartをshadow rootで包み、その内側の<div data-tuvix-sandbox-root="cart">にアプリをレンダリングし、style.cssを<head>ではなくshadow rootに読み込みます。マウントはローダーのtimeout(デフォルトは10000 ms)を上限として、これらのスタイルシートを待ちます。アプリが再度マウントされるとき、オーケストレーターはshadow rootを再利用します。

手動で使う場合:

ts
import { createSandbox } from '@tuvix.js/sandbox';

const container = document.getElementById('cart')!;
const sandbox = createSandbox({ css: true, js: true, strict: true, allowedGlobals: ['cartState'] });

const shadowRoot = sandbox.activate(container);
const target = shadowRoot ?? container;

const adopted = sandbox.css.adoptStyles(shadowRoot!, { urls: ['/apps/cart/style.css'] });
await adopted.ready;
target.append(Object.assign(document.createElement('p'), { textContent: 'Cart' }));

sandbox.js.execScript('window.cartState = { items: 0 }');
console.log('cartState' in window); // false

sandbox.deactivate(container); // children back in the light DOM, styles set aside
sandbox.activate(container); // same shadow root, styles restored
sandbox.destroy(container);

API ​

createSandbox ​

ts
import type { ISandbox, SandboxOptions } from '@tuvix.js/sandbox';

declare function createSandbox(options?: SandboxOptions): ISandbox;

new Sandbox(options)を返します。

オプション型デフォルト説明
cssbooleantrueactivateがコンテナーをshadow rootで包みます。falseの場合、activateはnullを返し、何もアタッチしません。
jsbooleantrueactivate/deactivateがsandbox.js.activeを切り替えます。どちらの場合も、sandbox.js.proxyWindowのプロキシは書き込みを実際のwindowから切り離します。
allowedGlobalsstring[][]strictモードでサンドボックス内のコードに代入を許可する追加のグローバル名。これらへの書き込みもプロキシに格納されます。
strictbooleanfalseサンドボックス内のコードが、組み込みの許可リストとallowedGlobalsに含まれないグローバルを代入または定義したときにTypeErrorを投げます。

Sandbox ​

ts
import { Sandbox } from '@tuvix.js/sandbox';

const sandbox = new Sandbox({ css: false, js: true });
console.log(sandbox.activate(document.body)); // null

createSandboxの背後にあるクラスです。ISandboxを実装しています。

メンバーシグネチャ動作
cssreadonly ICssSandbox使用するCssSandboxインスタンス。
jsreadonly IJsSandbox使用するJsSandboxインスタンス。
activate(container: HTMLElement) => ShadowRoot | nullcssが有効ならcss.wrap(container)を、続いてjsが有効ならjs.activate()を実行します。shadow rootまたはnullを返します。
deactivate(container: HTMLElement) => voidjs.deactivate()を実行し、続いてcss.unwrap(container)を実行します。
destroy(container: HTMLElement) => voiddeactivate(container)を実行し、続いてjs.reset()を実行して保存されたすべてのグローバルをクリアします。

CssSandbox ​

ts
import { CssSandbox } from '@tuvix.js/sandbox';

const cssSandbox = new CssSandbox();
const container = document.querySelector<HTMLElement>('#widget')!;

const shadowRoot = cssSandbox.wrap(container);
const style = cssSandbox.addStyle(shadowRoot, ':host { display: block; } p { color: teal; }');
cssSandbox.unwrap(container); // the <style> leaves the DOM and waits for the next wrap
cssSandbox.removeStyle(shadowRoot, style); // removed for good: the next wrap does not restore it
メソッドシグネチャ動作
wrap(container: HTMLElement) => ShadowRootコンテナーの子要素をopen shadow rootに移動し、そのshadow rootを返します。
unwrap(container: HTMLElement) => void子要素をlight DOMに戻し、shadow rootのスタイルシートを退避して、shadow rootに<slot>を残します。
addStyle(shadowRoot: ShadowRoot, css: string) => HTMLStyleElementcssを持つ<style>をshadow rootの末尾に追加します。
removeStyle(shadowRoot: ShadowRoot, styleEl: HTMLStyleElement) => voidstyleElを削除します。ホストがunwrapされている場合は退避リストからもstyleElを取り除くため、次のwrapでは復元されません。
adoptStyles(shadowRoot: ShadowRoot, styles: AdoptStylesOptions) => AdoptedStyles<link>要素と<style>要素をshadow rootの先頭に挿入します。

wrapと再マウント ​

DOM要素は一度アタッチしたshadow rootを外せません。wrapはこれに次のように対処します。

  1. このCssSandboxインスタンスがすでにコンテナーをwrapしていて、まだunwrapしていない場合、wrapは同じshadow rootを返し、何も移動しません。
  2. コンテナーがopenなshadowRootを持っている場合(以前のwrap、unwrap、または別のCssSandboxインスタンスによるもの)、wrapはunwrapが残した<slot>を削除し、unwrapが退避したスタイルシートを(先頭に、元の順序で)戻して、そのルートを再利用します。
  3. それ以外の場合はcontainer.attachShadow({ mode: 'open' })を呼び出します。

その後、light DOMのすべての子要素をshadow rootに移動します。wrapが例外を投げるのはattachShadowが失敗した場合のみです。たとえば、shadow rootをホストできない要素(<img>、<input>)や、すでにclosedなルートを持つ要素の場合です:[Tuvix Sandbox] Cannot attach Shadow DOM to <img>: ...。

unwrapとslot ​

unwrapはshadow rootの子要素をコンテナーに戻します。<style>ノードと<link rel="stylesheet">ノードは、addStyle、adoptStyles、アプリのどれに由来するかにかかわらずDOMから取り出されるため、アプリのCSSは適用されなくなり、ページにも届きません。これらはshadow rootとともに保持されます。shadow rootは残り、<slot data-tuvix-sandbox-slot>を1つ保持するため、ブラウザは戻された子要素をそれを通してレンダリングします。このインスタンスがwrapしていないコンテナーに対しては、unwrapは何もしません。

そのホストを次にwrapすると、このインスタンスか他のCssSandboxインスタンスかにかかわらず、slotを削除し、退避したスタイルシートを順序どおりに戻し、子要素を再び移動します。再マウント後にスタイルを再度取り込む必要はありません。ホストがunwrapされている間にremoveStyle(shadowRoot, el)またはadopted.remove()を呼び出すと、それらのスタイルシートは完全に削除されます。

adoptStyles ​

ts
import { CssSandbox } from '@tuvix.js/sandbox';

const cssSandbox = new CssSandbox();
const shadowRoot = cssSandbox.wrap(document.querySelector<HTMLElement>('#cart')!);

const adopted = cssSandbox.adoptStyles(shadowRoot, {
  urls: ['/apps/cart/style.css', '/shared/tokens.css'],
  cssText: ':host { display: block; }',
});

await adopted.ready; // every <link> fired load or error
console.log(adopted.elements.length); // 3

adopted.remove();
オプション型デフォルト説明
urlsreadonly string[][]各URLが順に<link rel="stylesheet" data-tuvix-style>になります。
cssTextstring | readonly string[]なし各文字列が<style data-tuvix-style>になり、linkの後に配置されます。

動作:

  • 要素はshadow rootの先頭、以前に取り込まれたスタイルの後に配置されます。そのため、アプリのコンテンツとaddStyleのスタイルはカスケードの後方に来ます。
  • このshadow rootにすでに取り込まれたURLはスキップされ、elementsには含まれません。unwrapが退避したlinkも対象です。以前の呼び出しで作成されたlinkの場合、readyはその既存のlinkも待ちます。
  • readyは各linkがloadまたはerrorを発火した時点で解決されます。rejectされることはないため、404のスタイルシートで処理が止まることはありません。jsdomはどちらのイベントも発火しないため、jsdomのテストではreadyをawaitしないでください。
  • remove()はこの呼び出しで挿入した要素(退避されたものを含む)のみを削除するため、次のwrapではそれらは復元されません。

JsSandbox ​

ts
import { JsSandbox } from '@tuvix.js/sandbox';

const jsSandbox = new JsSandbox(['analyticsQueue'], true);
jsSandbox.activate();

jsSandbox.execScript('window.analyticsQueue = []'); // allowed, stored on the proxy
console.log('analyticsQueue' in window); // false
console.log(Reflect.get(jsSandbox.proxyWindow, 'analyticsQueue')); // []

try {
  jsSandbox.execScript('window.tracker = {}');
} catch (error) {
  console.log(error instanceof TypeError); // true
}

jsSandbox.reset();

コンストラクター:new JsSandbox(allowedGlobals: string[] = [], strict = false)。

メンバーシグネチャ動作
proxyWindowreadonly WindowProxy実際のwindowに対するProxy。
activereadonly booleanactivateで設定され、deactivate/resetでクリアされるフラグ。
activate() => voidactiveをtrueに設定します。
deactivate() => voidactiveをfalseに設定します。保存されたグローバルは残ります。
execScript(code: string) => unknownnew Functionを通じて、codeをwith (scope)内のsloppyモードのスクリプトとして実行します。window、self、globalThis、トップレベルのthisはプロキシになり、自由識別子はプロキシを通じて解決されます。eval("...")はそのスコープ内で直接evalのままです。コードが返す値を返します。
reset() => void保存されたすべてのグローバルをクリアし、activeをfalseに設定します。

プロキシの動作:

  • 読み取りはまずサンドボックス自身のストアを確認し、なければ実際のwindowにフォールバックします。prototypeを持たない実際のwindowの関数(fetch、setTimeout)は、関数ごとに1回だけ実際のwindowにバインドされて返されるため、proxyWindow.fetch === proxyWindow.fetchとなります。
  • proxyWindow.window、.self、.globalThis、およびページがiframe内にない場合の.top/.parent/.framesは、プロキシ自身を返します。iframe内では、topとparentは実際のものが返されます。
  • 代入とObject.definePropertyは値をプロキシに格納します。実際のwindowは変更されません。valueを持たないディスクリプター({ enumerable: false })は保存された値を維持します。アクセサーディスクリプター(get/set)とconfigurable: falseを指定すると、Object.definePropertyはTypeErrorを投げます。
  • delete proxyWindow.xは保存された値を削除します。実際のwindowのプロパティに対してはfalseを返し、strictモードのコードではTypeErrorになります。
  • inはストアと実際のwindowの両方を確認します。

strictモード(strict: true):組み込みの許可リストとallowedGlobalsに含まれない名前に対する代入(window.x = 1または単独のx = 1)、トップレベルのvar x = 1、Object.definePropertyの呼び出しは、TypeError: [Tuvix Sandbox] Strict mode blocked write to global "tracker". Add it to allowedGlobals to permit it.を投げ、何も格納しません。このチェックはサンドボックスがアクティブかどうかにかかわらず実行されます。Symbolキーは常にブロック対象です。組み込みの許可リストには、console、タイマー、Promise、Map、JSON、fetch、URL、navigator、document、location、historyなどの標準グローバルが含まれます。許可された書き込み(これらの名前への書き込みを含む)はプロキシにとどまります。

型 ​

型説明
SandboxOptions{ css?, js?, allowedGlobals?, strict? }。createSandboxのオプションです。
ISandboxSandboxが実装するインターフェース:css、js、activate、deactivate、destroy。
ICssSandboxCssSandboxが実装するインターフェース。
IJsSandboxJsSandboxが実装するインターフェース。
AdoptStylesOptionsadoptStyles用の{ urls?, cssText? }。
AdoptedStylesadoptStylesが返す{ elements, ready, remove }。

エッジケース ​

  • execScript内では、x = 1、トップレベルのvar x = 1、window.x = 1、window.window.x = 1、top.x = 1、およびeval("...")内の同じ文は、いずれもxをプロキシに格納し、実際のwindowには格納しません。letとconstはスクリプト内のローカルにとどまります。
  • execScript内で未宣言の識別子を読み取ると、ReferenceErrorを投げずにundefinedを返します。また、withにはsloppyモードが必要なため、code内の"use strict"ディレクティブは効果がありません。
  • f()のような通常の関数呼び出しの内側では、sloppyモードのJavaScriptの定義どおり、thisは実際のグローバルオブジェクトです。そのような関数内のthis.x = 1は実際のwindowに書き込みます。execScriptで実行するコードではwindow.x = 1と書いてください。
  • execScriptはnew Functionを使うため、'unsafe-eval'を含まないContent Security Policyではブロックされます。
  • JsSandbox、Sandbox、createSandbox()は構築時にwindowを読み取るため、サーバー上ではReferenceErrorを投げます。CssSandboxがdocumentを必要とするのは、メソッドを呼び出したときだけです。
  • 2つのCssSandboxインスタンスは同じホストを共有できます。2つ目のwrapは1つ目のインスタンスのshadow rootを再利用します。
  • コンテナーがwrapされている間に追加したlight DOMの子要素はshadow rootの外側にとどまり、どのslotを通してもレンダリングされないため表示されません。代わりにshadow rootに追加してください。
  • Sandbox({ css: false }).activate()はnullを返します。コンテナーにレンダリングしてください。

対象外のこと ​

  • タイマー、イベントリスナー、document、<script>タグにはパッチを当てません。proxyWindowを経由しないコードは実際のページに影響します。
  • アプリが残したインターバルやリスナーはクリーンアップしません。アプリのunmountでクリアしてください。
  • コードに別のオリジンやグローバルオブジェクトを与えません。信頼できないコードには<iframe sandbox>を使ってください。
  • shadow rootをデタッチしません。DOMにはそのためのAPIがないため、unwrapは代わりにslotを残します。
  • 継承は止めません。ホストやその祖先に設定されたcolor、font-family、CSSカスタムプロパティは、引き続きshadow rootに伝わります。

0.4からの移行 ​

  • Sandbox({ css: false }).activate(container)はnullを返し、shadow rootをアタッチしません。0.4ではshadow rootをアタッチし、コンテナーの子要素を非表示にしていました。ISandbox.activateはShadowRoot | nullを返すようになりました。

    ts
    import { createSandbox } from '@tuvix.js/sandbox';
    
    const container = document.querySelector<HTMLElement>('#app')!;
    const sandbox = createSandbox({ css: false });
    
    // 0.4: const root: ShadowRoot = sandbox.activate(container);
    const root = sandbox.activate(container) ?? container;
  • strict: trueはブロックされた書き込みに対してTypeErrorを投げます。0.4では警告をログに出力し、値を格納していました。スクリプトが設定する名前をallowedGlobalsに追加してください。

  • execScriptはコードをwithブロック内で実行します。コードはstrictモードのコードにはできず、未宣言の識別子を読み取ると例外を投げずにundefinedを返し、単独のx = 1は実際のwindowではなくプロキシに格納されるようになりました。

  • unwrapはshadow rootのスタイルシートを保持し、次のwrapがそれらを復元します。0.4ではunwrapがそれらを削除していました。再マウント後にcssTextやaddStyleのスタイルを再度追加するコードは削除してください。残すと重複します。

  • 0.4のドキュメントには、js: trueがaddEventListener、タイマー、document.createElementをインターセプトすると書かれていました。実際にはそうしたことはなく、0.5でも同様です。