Installation
npm install @tuvix.js/sandbox
# or pnpm add @tuvix.js/sandboxQuand l'utiliser
Utilisez CssSandbox quand le CSS d'une micro app et celui de la page ne doivent pas s'influencer. Il déplace le DOM de l'app dans un shadow root ouvert sur le conteneur, pour que les sélecteurs de l'app restent à l'intérieur et que ceux de la page restent à l'extérieur. L'orchestrateur de @tuvix.js/core le fait pour vous quand vous enregistrez une app avec sandbox: true. Appelez ce paquet vous-même quand vous montez des apps sans l'orchestrateur.
Utilisez JsSandbox quand vous exécutez le texte d'un script tiers et voulez tenir ses écritures globales à l'écart du vrai window. Vous exécutez le code via execScript, qui résout window, this et les identifiants libres à travers un Proxy. L'orchestrateur ne l'applique pas.
Choisissez autre chose quand vous avez besoin d'une isolation forte pour du code non fiable. Une <iframe> avec l'attribut sandbox donne au code son propre objet global et sa propre origine ; ce paquet ne le fait pas.
Démarrage rapide
Via l'orchestrateur (CSS uniquement) :
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();L'orchestrateur enveloppe #cart dans un shadow root, rend l'app dans un <div data-tuvix-sandbox-root="cart"> à l'intérieur, et charge style.css dans le shadow root au lieu du <head>. Le montage attend ces feuilles de style, dans la limite du timeout du loader (10000 ms par défaut). L'orchestrateur réutilise le shadow root quand l'app est montée à nouveau.
À la main :
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
import type { ISandbox, SandboxOptions } from '@tuvix.js/sandbox';
declare function createSandbox(options?: SandboxOptions): ISandbox;Renvoie new Sandbox(options).
| Option | Type | Défaut | Signification |
|---|---|---|---|
css | boolean | true | activate enveloppe le conteneur dans un shadow root. Avec false, activate renvoie null et n'attache rien. |
js | boolean | true | activate/deactivate basculent sandbox.js.active. Le proxy de sandbox.js.proxyWindow tient les écritures à l'écart du vrai window dans tous les cas. |
allowedGlobals | string[] | [] | Noms globaux supplémentaires que le mode strict autorise le code isolé à assigner. Les écritures sur ces noms atterrissent quand même sur le proxy. |
strict | boolean | false | Lève une TypeError quand le code isolé assigne ou définit une globale hors de la liste d'autorisation intégrée et de allowedGlobals. |
Sandbox
import { Sandbox } from '@tuvix.js/sandbox';
const sandbox = new Sandbox({ css: false, js: true });
console.log(sandbox.activate(document.body)); // nullLa classe derrière createSandbox. Elle implémente ISandbox :
| Membre | Signature | Comportement |
|---|---|---|
css | readonly ICssSandbox | L'instance CssSandbox qu'elle utilise. |
js | readonly IJsSandbox | L'instance JsSandbox qu'elle utilise. |
activate | (container: HTMLElement) => ShadowRoot | null | css.wrap(container) quand css est activé, puis js.activate() quand js est activé. Renvoie le shadow root ou null. |
deactivate | (container: HTMLElement) => void | js.deactivate(), puis css.unwrap(container). |
destroy | (container: HTMLElement) => void | deactivate(container), puis js.reset(), qui efface toutes les globales stockées. |
CssSandbox
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| Méthode | Signature | Comportement |
|---|---|---|
wrap | (container: HTMLElement) => ShadowRoot | Déplace les enfants du conteneur dans un shadow root ouvert et le renvoie. |
unwrap | (container: HTMLElement) => void | Ramène les enfants dans le light DOM, met de côté les feuilles de style du shadow root et laisse un <slot> dans le shadow root. |
addStyle | (shadowRoot: ShadowRoot, css: string) => HTMLStyleElement | Ajoute un <style> contenant css à la fin du shadow root. |
removeStyle | (shadowRoot: ShadowRoot, styleEl: HTMLStyleElement) => void | Supprime styleEl. Quand l'hôte n'est plus enveloppé, retire aussi styleEl de la liste mise de côté, pour que le prochain wrap ne le restaure pas. |
adoptStyles | (shadowRoot: ShadowRoot, styles: AdoptStylesOptions) => AdoptedStyles | Insère des éléments <link> et <style> au début du shadow root. |
wrap et les remontages
Un élément DOM ne peut pas perdre son shadow root une fois qu'il est attaché. wrap gère ce cas :
- Si cette instance de
CssSandboxa déjà enveloppé le conteneur sans l'avoir désenveloppé,wraprenvoie le même shadow root et ne déplace rien. - Si le conteneur possède un
shadowRootouvert (issu d'unwrapprécédent, d'ununwrapou d'une autre instance deCssSandbox),wrapsupprime le<slot>laissé parunwrap, remet les feuilles de style queunwrapa mises de côté (au début, dans leur ordre d'origine) et réutilise le root. - Sinon, il appelle
container.attachShadow({ mode: 'open' }).
Il déplace ensuite chaque enfant du light DOM dans le shadow root. wrap ne lève une exception que si attachShadow échoue, par exemple sur un élément qui ne peut pas héberger de shadow root (<img>, <input>) ou qui possède déjà un root fermé : [Tuvix Sandbox] Cannot attach Shadow DOM to <img>: ....
unwrap et le slot
unwrap ramène les enfants du shadow root dans le conteneur. Il retire du DOM les nœuds <style> et <link rel="stylesheet">, qu'ils viennent de addStyle, de adoptStyles ou de l'app, pour que le CSS de l'app cesse de s'appliquer et n'atteigne jamais la page. Il les conserve avec le shadow root. Le shadow root reste et contient un seul <slot data-tuvix-sandbox-slot>, pour que le navigateur affiche les enfants restaurés à travers lui. unwrap ne fait rien pour un conteneur que cette instance n'a pas enveloppé.
Le prochain wrap de cet hôte, par cette instance de CssSandbox ou par une autre, supprime le slot, remet en ordre les feuilles de style mises de côté et ramène les enfants à l'intérieur. Vous n'avez pas besoin d'adopter à nouveau les styles après un remontage. Appeler removeStyle(shadowRoot, el) ou adopted.remove() pendant que l'hôte n'est pas enveloppé supprime définitivement ces feuilles.
adoptStyles
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();| Option | Type | Défaut | Signification |
|---|---|---|---|
urls | readonly string[] | [] | Chaque URL devient un <link rel="stylesheet" data-tuvix-style>, dans l'ordre. |
cssText | string | readonly string[] | aucun | Chaque chaîne devient un <style data-tuvix-style>, placé après les liens. |
Comportement :
- Les éléments sont placés au début du shadow root, après les styles adoptés précédemment, pour que le contenu de l'app et les styles de
addStyleviennent plus tard dans la cascade. - Une URL déjà adoptée dans ce shadow root est ignorée et n'apparaît pas dans
elements. Cela inclut les liens queunwrapa mis de côté.readyattend quand même ce lien existant quand un appel précédent l'a créé. readyse résout dès que chaque lien déclencheloadouerror. Il ne rejette jamais, donc une feuille de style en 404 ne vous bloque pas. jsdom ne déclenche aucun de ces événements, donc n'attendez pasreadydans les tests jsdom.remove()supprime uniquement les éléments insérés par cet appel, y compris ceux mis de côté, pour que le prochainwrapne les restaure pas.
JsSandbox
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();Constructeur : new JsSandbox(allowedGlobals: string[] = [], strict = false).
| Membre | Signature | Comportement |
|---|---|---|
proxyWindow | readonly WindowProxy | Proxy sur le vrai window. |
active | readonly boolean | Indicateur défini par activate et effacé par deactivate/reset. |
activate | () => void | Passe active à true. |
deactivate | () => void | Passe active à false. Les globales stockées restent. |
execScript | (code: string) => unknown | Exécute code comme un script en mode non strict (sloppy mode) dans with (scope) via new Function. window, self, globalThis et le this de premier niveau sont le proxy, et les identifiants libres sont résolus à travers lui. eval("...") reste un eval direct dans cette portée. Renvoie ce que le code renvoie. |
reset | () => void | Efface toutes les globales stockées et passe active à false. |
Le proxy :
- Les lectures consultent d'abord le stockage propre de la sandbox, puis se rabattent sur le vrai
window. Les fonctions du vrai window sansprototype(fetch,setTimeout) sont renvoyées liées au vraiwindow, une fois par fonction, doncproxyWindow.fetch === proxyWindow.fetch. proxyWindow.window,.self,.globalThis, ainsi que.top/.parent/.framesquand la page n'est pas dans une iframe, renvoient le proxy lui-même. Dans une iframe,topetparentrestent les vrais.- Les assignations et
Object.definePropertystockent la valeur sur le proxy. Le vraiwindowne change jamais. Un descripteur sansvalue({ enumerable: false }) conserve la valeur stockée. Les descripteurs d'accesseurs (get/set) etconfigurable: falsefont lever uneTypeErroràObject.defineProperty. delete proxyWindow.xsupprime une valeur stockée. Pour une propriété du vrai window, il renvoiefalse, ce qui lève uneTypeErrordans du code en mode strict.inconsulte le stockage et le vraiwindow.
Mode strict (strict: true) : une assignation (window.x = 1 ou un simple x = 1), un var x = 1 de premier niveau ou un appel à Object.defineProperty pour un nom hors de la liste d'autorisation intégrée et de allowedGlobals lève TypeError: [Tuvix Sandbox] Strict mode blocked write to global "tracker". Add it to allowedGlobals to permit it. et ne stocke rien. La vérification s'exécute que la sandbox soit active ou non. Les clés symboles sont toujours considérées comme bloquées. La liste d'autorisation intégrée couvre des globales standard comme console, les timers, Promise, Map, JSON, fetch, URL, navigator, document, location et history. Les écritures autorisées, y compris sur ces noms, restent sur le proxy.
Types
| Type | Signification |
|---|---|
SandboxOptions | { css?, js?, allowedGlobals?, strict? }, les options de createSandbox. |
ISandbox | Interface implémentée par Sandbox : css, js, activate, deactivate, destroy. |
ICssSandbox | Interface implémentée par CssSandbox. |
IJsSandbox | Interface implémentée par JsSandbox. |
AdoptStylesOptions | { urls?, cssText? } pour adoptStyles. |
AdoptedStyles | { elements, ready, remove } renvoyé par adoptStyles. |
Cas limites
- Dans
execScript,x = 1, unvar x = 1de premier niveau,window.x = 1,window.window.x = 1,top.x = 1et les mêmes instructions danseval("...")stockent tousxsur le proxy, jamais sur le vraiwindow.letetconstrestent locaux au script. - Lire un identifiant non déclaré dans
execScriptrenvoieundefinedau lieu de lever uneReferenceError, et une directive"use strict"danscoden'a aucun effet, carwithexige le mode non strict. - Dans un simple appel de fonction comme
f(),thisest le vrai objet global, comme le définit JavaScript en mode non strict.this.x = 1dans une telle fonction écrit sur le vraiwindow. Écrivezwindow.x = 1dans le code que vous exécutez viaexecScript. execScriptutilisenew Function, donc une Content Security Policy sans'unsafe-eval'le bloque.- Construire
JsSandbox,SandboxoucreateSandbox()litwindow, donc ils lèvent uneReferenceErrorsur le serveur.CssSandboxn'a besoin dedocumentque lorsque vous appelez ses méthodes. - Deux instances de
CssSandboxpeuvent partager un hôte. Le secondwrapréutilise le shadow root de la première instance. - Les enfants du light DOM ajoutés pendant que le conteneur est enveloppé restent hors du shadow root et ne passent par aucun slot, donc ils ne s'affichent pas. Ajoutez-les plutôt dans le shadow root.
Sandbox({ css: false }).activate()renvoienull. Rendez dans le conteneur.
Ce qu'il ne fait pas
- Il ne patche ni les timers, ni les écouteurs d'événements, ni
document, ni les balises<script>. Le code qui ne passe pas parproxyWindowaffecte la vraie page. - Il ne nettoie pas les intervalles ni les écouteurs qu'une app laisse derrière elle. Supprimez-les dans le
unmountde l'app. - Il ne donne pas au code une origine ou un objet global séparés. Utilisez une
<iframe sandbox>pour du code non fiable. - Il ne détache pas un shadow root. Le DOM n'a pas d'API pour cela ;
unwraplaisse un slot à la place. - Il n'empêche pas l'héritage.
color,font-familyet les propriétés personnalisées CSS définies sur l'hôte ou ses ancêtres se propagent toujours dans le shadow root.
Migrer depuis la 0.4
Sandbox({ css: false }).activate(container)renvoienullet n'attache aucun shadow root. En 0.4, il en attachait un et masquait les enfants du conteneur.ISandbox.activaterenvoie désormaisShadowRoot | null:tsimport { 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: truelève uneTypeErrorsur une écriture bloquée. En 0.4, il journalisait un avertissement et stockait la valeur. Ajoutez àallowedGlobalsles noms que vos scripts définissent.execScriptexécute le code dans un blocwith. Le code ne peut pas être en mode strict, lire un identifiant non déclaré renvoieundefinedau lieu de lever une exception, et un simplex = 1atterrit désormais sur le proxy au lieu du vraiwindow.unwrapconserve les feuilles de style du shadow root et le prochainwraples restaure. En 0.4,unwraples supprimait. Retirez le code qui ajoute à nouveau des stylescssTextouaddStyleaprès un remontage, sinon vous obtenez des doublons.La documentation de la 0.4 indiquait que
js: trueinterceptaitaddEventListener, les timers etdocument.createElement. Ce n'a jamais été le cas, et la 0.5 ne le fait pas non plus.