Les pièges Proxy et les invariants que le moteur impose
Une sonde sur des pièges fixes : quels pièges se déclenchent, quelles réponses le moteur refuse avec un TypeError, et ce que fait un proxy révoqué ou non appelable.
BotBrowser Team
Vous voulez la documentation structurée pour Empreinte ?
Cet article fait partie de la bibliothèque éditoriale. Pour les étapes de configuration, la référence et les mises à jour continues, passez directement à la section docs.
BotBrowser prend en charge la relecture d’un fixture autorisé du même origine sous un profil déclaré et conserve un relevé borné des pièges qu’une page a installés et des réponses que le moteur a refusées, mais il ne peut pas faire qu’un proxy viole un invariant que le moteur impose, ne peut pas garder utilisable un proxy révoqué, et ne peut pas changer en appel un piège posé sur une cible non appelable. Cette revue lance une sonde sur neuf pièges fixes et consigne le piège qui se déclenche pour une lecture simple, les six invariants qui rejettent par un TypeError une réponse de piège plausible, les deux pièges qui convertissent réellement une valeur quand la cible le permet, ce qu’un proxy révoqué fait à chaque accès, et pourquoi une cible objet ne peut pas répondre à un appel.
Résumé
- Une lecture simple de propriété ne déclenche que le piège
get, donc la sonde afficheorder=get:asans aucune lecture de descripteur. - Signaler moins de clés qu’une cible non extensible n’en possède lève un
TypeError, donc un piège ne peut pas cacher une propriété que la cible a conservée. - Signaler une propriété non configurable que la cible n’a pas lève un
TypeError, donc un piège ne peut pas inventer une propriété verrouillée. - Renvoyer
truedepuissetpour une propriété gelée lève unTypeErroret laisse la cible inchangée à1. - Un piège
setqui écrit dans la cible la modifie bien, donc la sonde affichetarget=42après avoir doublé 21. - Changer le prototype d’une cible extensible est autorisé, tandis que le même piège sur une cible non extensible lève un
TypeError. - Un proxy révoqué lève un
TypeErrorsur une lecture, un testinetObject.keysde la même façon. - Un piège
applydouble le travail en50, tandis que le même piège sur une cible objet lève unTypeErrorparce que la cible n’est pas appelable. - Un piège
deletePropertyne peut pas signaler comme supprimée une propriété non configurable, et la propriété reste.
Sommaire
- Quel piège déclenche une lecture simple
- Pourquoi le moteur rejette une réponse de piège plausible
- Où un piège change réellement une valeur
- Ce que font un proxy révoqué et une cible non appelable
- Consigner une lecture de proxy
Quel piège déclenche une lecture simple
Un proxy transmet les opérations à des pièges, et chaque opération n’atteint qu’un ensemble précis d’entre eux. La sonde lit une propriété et consigne chaque piège qui s’est exécuté :
node=v26.9.0
read-order -> value=1 order=get:a
ownKeys-invariant -> TypeError
gopd-invariant -> TypeError
set-invariant -> TypeError targetValue=1
set-doubled -> target=42
prototype-invariant -> extensible=allowed non-extensible=TypeError
revoked -> TypeError,TypeError,TypeError
apply-trap -> result=50 object-target=TypeError
delete-invariant -> TypeError stillThere=true
Seul get s’est exécuté. Cela compte parce qu’une page qui instrumente un proxy en comptant les appels à getOwnPropertyDescriptor pour détecter les lectures ne compte rien, et une page qui attend que has s’exécute à chaque lecture a une surprise. Chaque opération correspond à un piège selon une correspondance un à un, et une lecture est la plus étroite.
La même correspondance décide de ce qu’un contrôle de détection observe. Une sonde d’empreinte qui compare un objet ordinaire à un proxy le fait souvent en déclenchant des opérations qui forcent un piège, et une lecture seule en force un ; une lecture de descripteur en force un autre, et la ligne de la sonde montre que les deux ne sont pas déclenchés par la même instruction. C’est pourquoi un proxy qui répond à une lecture peut encore ressembler à un objet ordinaire pour un contrôle qui ne fait que lire.
La correspondance complète est finie et vaut la mémorisation, parce que chaque entrée décide quel piège atteint une instruction donnée. get sert une lecture de propriété, set sert une affectation, has sert l’opérateur in, deleteProperty sert delete, ownKeys sert Object.keys et Reflect.ownKeys, getOwnPropertyDescriptor sert Object.getOwnPropertyDescriptor et la recherche interne que Object.keys effectue avant ownKeys, defineProperty sert Object.defineProperty, getPrototypeOf et setPrototypeOf servent les deux opérations de prototype, isExtensible et preventExtensions servent les deux opérations d’extensibilité, et apply et construct servent un appel et un new sur une cible appelable. Une page qui attend un seul piège d’une instruction a raison exactement quand cette instruction figure sur la liste.
Deux de ces entrées vont par paire, ce qui produit un ordre plutôt qu’un seul appel. Object.keys invoque d’abord ownKeys pour obtenir la liste des candidats, puis, pour chaque clé qu’il veut garder, invoque getOwnPropertyDescriptor pour vérifier si la propriété est énumérable. Un proxy qui répond à ownKeys mais pas à getOwnPropertyDescriptor fonctionne donc encore, parce que le piège de descripteur par défaut transmet à la cible. Un proxy qui répond à getOwnPropertyDescriptor par un descripteur non énumérable pour une clé qu’il a listée dans ownKeys retire silencieusement cette clé du résultat, et une page qui compte les appels à ownKeys pour en déduire combien de propriétés un proxy expose en compte trop, exactement du nombre des clés qu’il a filtrées.
Les mêmes paires apparaissent dans un benchmark qui compare le coût d’un proxy à celui d’un objet ordinaire. Chaque opération instrumentée coûte au moins l’appel au piège plus ce que fait le piège, et les opérations appariées coûtent deux appels de piège dans une seule instruction, c’est pourquoi un proxy sur une boucle intensive de Object.keys est sensiblement plus lent qu’un proxy sur une boucle intensive de lectures. La sonde ne peut pas mesurer cela sur neuf opérations, et l’ordre est identique dans les deux cas, donc la différence de coût est une propriété de la correspondance plutôt que d’un corps de piège particulier.
Pourquoi le moteur rejette une réponse de piège plausible
Un piège peut renvoyer une valeur qui contredit la cible, et le moteur impose un ensemble fixe d’invariants qui rejettent ces réponses. La sonde en provoque six et chacune lève un TypeError :
ownKeys-invariant -> TypeError
gopd-invariant -> TypeError
Le premier cas est une cible non extensible à deux clés où le piège ownKeys n’en signale qu’une, et le moteur refuse qu’un piège cache une clé que la cible possède encore. Le second cas a un piège qui signale une propriété non configurable que la cible n’a pas, et le moteur refuse qu’un piège invente une propriété verrouillée. Les deux réponses paraissent raisonnables prises isolément, et toutes deux sont exactement les incohérences que l’invariant existe pour attraper.
Le troisième cas est celui qui fait échouer un proxy de cache. Une cible gelée plus un piège set qui renvoie true lève une erreur, et la cible reste inchangée :
set-invariant -> TypeError targetValue=1
Le piège a déclaré un succès, et l’assertion que vérifie l’invariant est de savoir si l’état propre de la cible est d’accord. Une page qui enveloppe un objet de configuration gelé dans un proxy de journalisation et renvoie toujours true depuis set obtient une erreur au premier essai d’écriture plutôt qu’une écriture ignorée en silence, ce qui est le résultat correct et celui auquel l’auteur de la page ne s’attend généralement pas.
La quatrième paire porte sur l’identité et la suppression :
prototype-invariant -> extensible=allowed non-extensible=TypeError
delete-invariant -> TypeError stillThere=true
Changer le prototype signalé d’une cible extensible est autorisé, parce que la cible peut encore changer pour s’y conformer ; le même piège sur une cible non extensible lève une erreur, parce que la cible ne peut plus passer au prototype déclaré. Supprimer une propriété non configurable via un piège qui renvoie true lève une erreur, parce que la cible possède toujours la propriété. Dans les deux cas l’invariant est la même idée sous une autre clé : un piège ne peut pas signaler un état d’objet que les attributs propres de la cible interdisent.
La règle générale derrière les six vaut d’être énoncée une fois : un proxy est un filtre, pas un remplacement. Le moteur compare chaque réponse de piège aux attributs propres de la cible, et toute réponse qui exigerait que la cible viole ces attributs est rejetée. C’est pourquoi un proxy ne peut pas servir à faire croire qu’un objet gelé est modifiable, ni qu’un objet scellé a moins de clés, ni qu’une propriété est à la fois présente et non configurable quand la cible n’a pas une telle propriété.
Il y a un cinquième invariant que la sonde exerce via set et un dernier plus facile à voir par paire avec lui. L’invariant de set a deux moitiés : un piège ne peut pas signaler un succès pour une propriété que la cible marque non modifiable, et un piège ne peut pas signaler un succès pour une propriété que la cible n’a pas tandis que la cible est non extensible. Le cas gelé de la sonde est la première moitié. La seconde apparaît quand le piège set d’un proxy sur un objet scellé renvoie true pour une clé entièrement nouvelle : la cible ne peut pas croître, donc le moteur lève une erreur même si le piège ne l’a jamais touchée. Un proxy de journalisation qui transmet chaque écriture avec Reflect.set ne voit jamais l’une ni l’autre, parce que Reflect.set renvoie false dans les deux cas, et le true du piège le contredirait.
Le schéma qui rend tous ces invariants sûrs à utiliser est que Reflect renvoie exactement la réponse que l’invariant attend. Reflect.ownKeys(target) renvoie les clés propres de la cible, Reflect.getOwnPropertyDescriptor(target, key) renvoie le descripteur propre de la cible ou undefined, Reflect.set(target, key, value) renvoie si la cible a accepté l’écriture, et Reflect.deleteProperty(target, key) renvoie si la suppression a réussi. Un piège qui renvoie un résultat de Reflect n’est donc jamais en conflit avec la cible, c’est pourquoi le proxy de transmission idiomatique est un mince appel à Reflect par piège et pourquoi c’est dans un piège qui renvoie un littéral écrit à la main que les invariants mordent.
Le sixième invariant porte sur les prototypes, et les deux cas de la sonde en montrent précisément la forme. Un piège peut signaler n’importe quel prototype tant que la cible est extensible, parce que la cible peut encore être déplacée pour s’y conformer, et le moteur permet le signalement même avant que la cible ne bouge. Dès que la cible devient non extensible, le prototype signalé doit être égal au prototype réel de la cible, parce que la cible ne peut plus bouger. Une page qui renvoie un prototype fixe depuis un piège fonctionne donc jusqu’à ce que quelque chose gèle la cible, c’est pourquoi la défaillance apparaît tardivement et seulement dans un chemin de code qui scelle l’objet.
Où un piège change réellement une valeur
Deux des pièges de la sonde font plus qu’observer, et les deux sont autorisés parce que la cible soutient le changement :
set-doubled -> target=42
apply-trap -> result=50 object-target=TypeError
Le piège set a doublé la valeur écrite 21 pour donner 42 sur la cible, et l’écriture a réussi parce que la cible est ordinaire et extensible. Un piège qui change une valeur comme celui-ci est la façon normale dont un proxy calcule une valeur dérivée, et l’invariant ne s’y oppose pas parce que le piège a maintenu la cible cohérente avec ses propres attributs. Le piège apply a multiplié par dix le résultat d’un appel, donc appeler le proxy avec 2 et 3 a renvoyé 50 au lieu de 5, de nouveau parce que la cible appelable rendait ce calcul licite.
La seconde moitié de cette ligne est la limite. Le même piège apply sur une cible objet ordinaire lève un TypeError, parce qu’un proxy n’est appelable que quand sa cible est appelable, et un objet n’a pas de [[Call]] à transmettre. Une page qui veut un proxy appelable doit partir d’une fonction, ce qui est une petite contrainte structurelle qu’un proxy construit à partir d’un objet viole en silence.
Le piège construct a la même exigence et une règle de plus. Un proxy sur une fonction peut servir new via un piège construct, et le piège doit renvoyer un objet, parce qu’un appel de constructeur doit en produire un. Un piège qui renvoie une primitive lève un TypeError, ce qui est le jumeau côté construct de l’exigence côté apply que la cible soit appelable. Une page qui enveloppe une classe dans un proxy pour intercepter la construction doit faire renvoyer l’instance au piège, et une page qui l’oublie renvoie undefined et obtient l’erreur plutôt qu’une instance silencieuse.
Ce qu’une copie garde de l’objet qu’un proxy enveloppe est la question à laquelle structuredClone face à JSON répond. Un piège qui renvoie un objet entièrement différent est licite, et c’est là qu’un proxy cesse d’être transparent. Un piège get qui renvoie un nouvel emballage pour un tableau garde l’invariant, parce que get n’a pas d’attribut auquel il doive se conformer, et l’appelant reçoit l’emballage au lieu du tableau stocké. Une page qui compare proxy.items === target.items obtient alors false, et une page qui attend l’identité sur deux lectures obtient deux emballages différents sauf si le piège les met en cache. L’invariant permet tout cela, c’est pourquoi un relevé du comportement d’un proxy doit nommer ce que renvoie le piège plutôt que de supposer qu’il transmet.
Ce que font un proxy révoqué et une cible non appelable
Un proxy qui cesse de fonctionner à l’usage a la même faiblesse qu’une collection qui lâche une entrée d’elle-même : collections faibles et rétention mémoire montre comment une page perd l’accès à une valeur qu’elle croyait détenir. Proxy.revocable renvoie un proxy et une fonction de révocation, et révoquer le proxy fait échouer chaque opération :
revoked -> TypeError,TypeError,TypeError
Une lecture de propriété, un test in et un appel à Object.keys ont chacun levé un TypeError après la révocation. C’est une défaillance plus forte que la suppression de la référence, parce qu’une page qui gardait une référence active au proxy ne peut toujours pas l’utiliser. C’est aussi pourquoi un proxy révoqué est un moyen utile de fermer une capacité : le consommateur détient le handle, et le handle cesse de fonctionner quand le producteur le révoque, sans coopération du consommateur.
La révocation est aussi irréversible, et la fonction de révocation est idempotente. Appeler revoke() une seconde fois ne fait rien et ne lève rien, donc une page qui protège son nettoyage avec un drapeau n’y gagne rien au-delà de la clarté. Une page qui tente de recréer le proxy à partir de la même cible après révocation obtient un proxy nouveau et indépendant, parce que la révocation est attachée à l’objet proxy et non à la cible, c’est pourquoi un proxy révoqué ne peut pas être ressuscité en enveloppant de nouveau la même cible.
Deux détails rendent un proxy révoqué difficile à détecter par inspection. Premièrement, typeof sur un proxy appelable révoqué signale encore function, donc un contrôle qui ne teste que typeof laisse passer un proxy fonction révoqué. Deuxièmement, l’erreur survient à l’usage et non à la construction, donc une page qui construit le proxy pendant l’initialisation et l’utilise plus tard voit la défaillance loin de la révocation qui l’a causée. Les deux relèvent du même dessein : la révocation est un état d’exécution, pas un état structurel.
Une page qui veut détecter un proxy plutôt que l’utiliser a une tâche plus ardue, et les invariants en sont la raison. Un proxy n’a aucune propriété propre à lui, donc un contrôle structurel qui lit Object.getOwnPropertyNames(proxy) voit les noms de la cible, et un contrôle qui lit Object.getPrototypeOf(proxy) voit le prototype de la cible sauf si le piège en signale un autre. Le seul signal qui subsiste est le comportement du piège lui-même : un piège qui renvoie une valeur que la cible ne pouvait pas produire est la seule trace observable, ce qui est exactement ce que les invariants contraignent et pourquoi un proxy de transmission bien écrit n’en laisse presque aucune.
Consigner une lecture de proxy
Une lecture de proxy soutient une affirmation étroite : ce moteur, ces pièges, cette cible, ont produit ces observations. Quatre champs la rendent vérifiable :
- Le type d’accès, parce que chaque opération a son propre piège associé.
- Les attributs propres de la cible, parce qu’une cible gelée ou non extensible change quelles réponses de piège sont licites.
- La réponse du piège, parce que l’invariant compare cette réponse à la cible plutôt que de l’accepter.
- Si le proxy a été révoqué ou si sa cible était appelable, parce que les deux changent la défaillance en
TypeErrorà l’usage.
Sources publiques
- MDN : Proxy
- MDN : Proxy.revocable()
- MDN : Reflect
- ECMAScript : Proxy Objects
- BotBrowser : référence de cohérence d’automatisation
Conclusion pratique
Traitez un proxy comme un filtre au-dessus d’une cible réelle : chaque réponse de piège est vérifiée contre les attributs propres de la cible, et celles qui ne concordent pas lèvent un TypeError à l’usage. Nommez l’opération quand un relevé décrit un proxy, parce qu’une lecture et une lecture de descripteur atteignent des pièges différents et qu’un seul a pu s’exécuter. Utilisez une cible fonction pour un proxy appelable et un objet ordinaire uniquement pour un proxy de propriétés, et rappelez-vous qu’un proxy révoqué garde son typeof et n’échoue qu’à l’usage. Pour l’objet dont le proxy filtre les propriétés, lisez l’ordre des clés d’objet ; pour la sémantique de copie qu’un proxy laisse passer, lisez structuredClone face à JSON.
Cet article fait partie du guide : Qu'est-ce qu'une empreinte de navigateur ?
Articles Connexes
WeakMap et WeakSet : garder et lâcher des clés
EmpreinteRaison d’annulation, composition et écouteurs tardifs
EmpreinteOrdre des clés : les entiers trient, les chaînes restent
EmpreinteIntl.Collator : l’ordre de locale face aux unités de code
EmpreintePromise : ce qui échoue d’abord
EmpreinteTri de tableau et comparateurs : un piège subtil
Faites passer BotBrowser de la recherche à la production
Utilisez ces guides pour comprendre le modèle, puis passez à la validation multi-plateforme, aux contextes isolés et au déploiement navigateur prêt pour l'échelle.