Skip to content

サンドボックス ​

@tuvix.js/sandboxには2つのツールがあります。CssSandboxはアプリをShadow Rootの中に置き、アプリのCSSとページのCSSを分離します。JsSandboxはプロキシのwindowに対してコードを実行し、グローバルへの書き込みをプロキシ上に留めます。オーケストレーターはsandbox: trueで登録したアプリにCssSandboxを使います。JsSandboxは適用しません。

アプリごとのCSS分離 ​

ts
import { createOrchestrator } from 'tuvix.js';

const orchestrator = createOrchestrator();

orchestrator.register({
  name: 'widget',
  entry: {
    scripts: ['https://cdn.example.com/widget/main.js'],
    styles: ['https://cdn.example.com/widget/main.css'],
    type: 'module',
  },
  container: '#widget',
  activeWhen: '/',
  sandbox: true, // same as { css: true }
});

このアプリに対して、オーケストレーターは次の処理を行います。

  1. #widgetにopenのShadow Rootをアタッチします(以前のマウントで作成したものがあれば再利用します)。
  2. その中に<div data-tuvix-sandbox-root>を作成し、その要素をcontainerとしてmountに渡します。
  3. エントリーのstylesをdocument.headではなくShadow Rootの中に<link>タグとして追加し、mountを呼び出す前に、最大loader.timeoutまでそれらの読み込みを待ちます。

ページのCSSはアプリに届かなくなり、アプリのCSSもページに届かなくなります。colorやfont-familyなどの継承プロパティは、引き続きホスト要素から流れ込みます。これを止めるには、アプリのCSSで:hostに設定します。

アプリがグローバルなセレクター(button { ... }、CSSリセット)を含む場合や、ページのCSSがアプリを壊す場合に使います。

エッジケース ​

  • フレームワークが実行時に注入するスタイルは、Shadow Rootの外にあるdocument.headに入ります。ViteのCSS注入、CSS-in-JSライブラリ、Angularのコンポーネントスタイルはすべてこの動作をします。CSSファイルを配布してentry.stylesに指定するか、自分でShadow RootにCSSを追加します。
  • document.bodyにレンダリングするポータル、モーダル、トーストはShadow Rootの外に出るため、アプリのスタイルが適用されません。
  • アプリ内からのdocument.querySelectorは、Shadow Root内の要素を見つけられません。代わりにcontainerから検索します。
  • ローダー関数のエントリーはstylesを指定しないため、バンドラーがそのCSSをdocument.headに注入し、Shadow Rootがそれを遮断します。サンドボックス化するアプリは{ scripts, styles, type }形式のエントリーで登録します。

CssSandboxを直接使う ​

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

declare const container: HTMLElement;

const css = new CssSandbox();
const shadow = css.wrap(container); // moves existing children into the shadow root

const adopted = css.adoptStyles(shadow, {
  urls: ['/widget/main.css'],
  cssText: ':host { display: block; font-family: system-ui; }',
});
await adopted.ready; // resolves once each <link> fired load or error

const button = document.createElement('button');
button.textContent = 'Buy';
shadow.append(button);

// Later
adopted.remove();
css.unwrap(container);
メソッド効果
wrap(container)openのShadow Rootをアタッチし(既存のopenのものがあれば再利用し)、コンテナの子要素をその中に移動します。
adoptStyles(shadow, { urls, cssText })Shadow Rootの先頭に<link>要素と<style>要素を挿入します。そこですでに適用済みのURLはスキップします。{ elements, ready, remove }を返します。
addStyle(shadow, css) / removeStyle(shadow, el)<style>を1つ追加または削除します。
unwrap(container)スタイル以外のノードをLight DOMに戻し、それらがレンダリングされるように<slot>を残します。<style>とスタイルシートの<link>ノード(addStyleとadoptStylesで追加したものを含む)はDOMから取り出され、そのコンテナを次にwrapしたときに元の順序で戻されます。

要素がShadow Rootをホストできない場合(例:<input>)や、すでにclosedのShadow Rootを持つ場合、wrapは例外を投げます。readyがrejectされることはありません。jsdomはリンクのloadを発火しないため、jsdomのテストではreadyをawaitしないでください。コンテナがunwrapされている間にremoveStyleまたはadopted.remove()を呼び出すと、そのスタイルシートは次のwrapで戻されません。

JsSandbox ​

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

const js = new JsSandbox(['dataLayer'], /* strict */ true);

js.execScript('window.dataLayer = []'); // allowed, stored on the proxy
console.log('dataLayer' in window); // false, unless the page defined it

try {
  js.execScript('counter = 1'); // bare assignment to a global that is not allowed
} catch (error) {
  console.log(error instanceof TypeError); // true: strict mode blocked the write
}

const loose = new JsSandbox(); // not strict
loose.execScript('var version = 2; build = 7;');
console.log(Reflect.get(loose.proxyWindow, 'version'), 'version' in window); // 2 false

js.reset(); // forget every stored global
  • プロキシを通じた書き込みとObject.definePropertyは、サンドボックス上のストアに保存されます。読み取りはまずそのストアを確認し、次に実際のwindowを確認します。
  • strict: trueの場合、組み込みの許可リストとallowedGlobalsにないグローバルへの代入、トップレベルでのvar宣言、Object.definePropertyはTypeErrorを投げ、何も保存しません。
  • execScript(code)はwith (scope)の中でcodeを実行し、window、self、globalThis、thisをプロキシにバインドします。裸の代入(x = 1)、トップレベルのvar x、window.x = 1はすべてプロキシに保存されます。letとconstはスクリプト内のローカルに留まります。
  • withはsloppyモードを必要とするため、execScriptの中では、宣言されていない識別子を読み取るとReferenceErrorを投げずにundefinedを返し、"use strict"ディレクティブは効果を持ちません。new Functionを使うため、Content Security Policyにunsafe-evalが必要です。
  • activate()とdeactivate()はactiveフラグを設定します。プロキシはどちらの状態でも書き込みを保存します。

JsSandboxが対象とするのは、proxyWindowを通じてグローバルにアクセスするコードだけです。<script>タグで読み込んだスクリプト、バンドルされたモジュール、タイマー、イベントリスナーはすべて実際のwindowを使います。実行するスニペットでの意図しないグローバルへの書き込みを防ぐものとして扱い、セキュリティ境界としては扱わないでください。

組み合わせたサンドボックス ​

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

declare const container: HTMLElement;

const sandbox = createSandbox({ css: true, js: false });

const shadow = sandbox.activate(container); // ShadowRoot | null
if (shadow) sandbox.css.addStyle(shadow, '.box { padding: 8px; }');

sandbox.deactivate(container); // unwrap
sandbox.destroy(container); // deactivate + reset the JS store

cssがfalseの場合、activateはnullを返し、何もアタッチしません。

マイクロアプリ内で使う ​

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

const css = new CssSandbox();

export default defineMicroApp({
  name: 'widget',
  mount({ container }) {
    const shadow = css.wrap(container);
    css.addStyle(shadow, 'p { color: rebeccapurple; }');
    const text = document.createElement('p');
    text.textContent = 'Isolated';
    shadow.append(text);
  },
  unmount({ container }) {
    container.shadowRoot?.replaceChildren();
    css.unwrap(container);
  },
});

オーケストレーターがアプリをマウントする場合は、registerでsandbox: trueを指定する方法を優先します。同じ処理を行い、再マウントの間でShadow Rootを再利用します。

サンドボックスを使うべきでない場合 ​

  • アプリがdocument.bodyへのポータルを使い、そこでスタイルを必要とする場合。
  • ページのデザインシステムでアプリをスタイリングすべき場合。
  • 本当のJavaScript分離が必要な場合。<iframe>を使います。