ENbext.dev
DocumentationExtensions

Traduction automatique depuis l’anglais. Consulter l’original.

Sur cette page

Plugins V8

Le niveau V8 exécute le code des extensions dans des isolats V8 intégrés via rusty_v8. Il utilise le même moteur que bext emploie déjà pour le rendu côté serveur : PRISM (TSX vers des chaînes HTML) et le Chemin de compatibilité Next.js (React renderToString/renderToReadableStream) s’exécutent dans V8. Aucun environnement d’exécution supplémentaire à distribuer, aucun coût mémoire supplémentaire pour la première extension et aucune compilation JIT distincte à amorcer. Le niveau QuickJS vise une faible empreinte et une interprétation déterministe ; le niveau V8 vise le débit brut des middlewares qui traitent chaque requête.

Choisissez V8 lorsque votre extension effectue un travail conséquent sur le chemin critique : règles WAF riches en expressions régulières, validation de schémas JSON, rendu de modèles, décodage de jetons ou toute opération pour laquelle l’interpréteur de bytecode de QuickJS devient le facteur limitant.

Caractéristiques d'exécution#

Propriété Défaut Configurable
Tas initial 1 Mo sandbox.limits.initial_heap_bytes
Tas maximum 64 Mo sandbox.limits.max_heap_bytes
Carburant par appel 200 ticks sandbox.limits.fuel_per_call
Intervalle du dispositif de surveillance 50 ms sandbox.limits.tick_interval_ms
Démarrage à froid par isolat ~15 ms N/A
Surcoût par isolat Environ 2 Mo résidents N/A
Échauffement en piscine Désireux pool.size

Au démarrage, V8PluginPool crée à l’avance size isolats afin que acquire() sur le chemin des requêtes ne subisse jamais de démarrage à froid. Chaque isolat reste attaché à son propre thread de travail pendant toute la vie du processus ; voir la section Modèle de threads ci-dessous.

Le tableau des fonctions de l'hôte#

V8 n'a pas d'éditeur de liens comme le fait wasmtime. Au lieu de cela, chaque liaison d'hôte est suspendue à un seul objet global bext installé sur le modèle de contexte de l'isolat. Le code invité appelle bext.log(...), bext.middleware_continue(), etc. — pas d'importation, pas de require(), pas de résolution de module.

Les charges utiles qui traversent l'ABI sont des chaînes JSON. Un gestionnaire de middleware qui souhaite court-circuiter une réponse crée un objet JS et appelle bext.middleware_respond(JSON.stringify(...)). L'adaptateur côté Rust analyse l'enveloppe et la transforme en un RequestAction::Respond(PluginResponse) natif. Cela permet à l'ABI de rester simple, indépendant du langage et trivial pour évoluer sans casser les plugins existants.

Liaisons disponibles#

javascript
// Structured logging — routed to the host tracing pipeline.
bext.log("info", "request seen");
bext.log("warn", "slow DB query");
bext.log("error", "upstream 502");

// Emit a metric — name, numeric value, optional JSON-stringified tags.
bext.emit_metric("requests_blocked", 1, JSON.stringify({ reason: "geo" }));

// Read the plugin's config section from bext.config.toml as a JSON string.
const config = JSON.parse(bext.get_config());

// Middleware short-circuit — argument is JSON with {status, headers, body}.
return bext.middleware_respond(JSON.stringify({
  status: 403,
  headers: [["content-type", "text/plain"]],
  body: "forbidden",
}));

// Middleware continue — no arguments, always returns the continue envelope.
return bext.middleware_continue();

// Lifecycle hook — name and arbitrary JSON payload for observability.
bext.lifecycle_event("request_complete", JSON.stringify({ path, status }));

// Cache-backend observability — emit hit/miss/evict events to tracing.
bext.cache_event("hit", key);

// Transform plugin "no change" marker.
return bext.transform_unchanged();

Les liaisons ci-dessus couvrent les quatre caractéristiques de fonctionnalités actuellement câblées pour la V8 : Middleware, Lifecycle, Transform et Cache. Les plafonds de base (E1) et intermédiaire (E2) restants atterrissent dans un suivi – voir KNOWN_DIFFERENCES.md dans la caisse bext-plugin-v8 pour la matrice exacte.

Écrire un plugin V8#

Le point d'entrée d'un plugin V8 est une expression unique qui s'évalue en un objet simple. L'adaptateur appelle les méthodes sur cet objet par leur nom — on_request, on_request_complete, matches, transform, etc. — et s'attend à ce que chaque méthode renvoie une chaîne JSON (ou null ).

javascript
// plugin.js — returned expression is the plugin object.
({
  on_request(ctx) {
    const parsed = JSON.parse(ctx);
    if (parsed.path.startsWith("/admin") && !parsed.headers.find(([k]) => k === "authorization")) {
      return bext.middleware_respond(JSON.stringify({
        status: 401,
        headers: [["www-authenticate", "Bearer"]],
        body: "unauthorized",
      }));
    }
    return bext.middleware_continue();
  },

  on_request_complete(event) {
    const parsed = JSON.parse(event);
    bext.emit_metric("request_latency_ms", parsed.duration_ms, "{}");
    bext.lifecycle_event("request_complete", event);
    return null;
  },
})

Le RequestContext transmis à on_request est sérialisé au format JSON avant l'appel et contient method, path, hostname, headers, query_string, peer_ip, tenant_id, site_id et le sac opaque extensions.

Application du bac à sable#

Le V8 n'expose pas de carburant par instruction de type wasmtime. Le niveau V8 s’en rapproche :

  1. En haut de chaque appel invité, l'adaptateur écrit fuel_per_call dans un AtomicI64 partagé.
  2. Un thread de surveillance envoie une requête ping à l'isolat via v8::Isolate::request_interrupt toutes les millisecondes tick_interval_ms.
  3. Le rappel d'interruption décrémente le compteur de carburant ; lorsqu'il devient non positif, il appelle terminate_execution sur l'isolat.
  4. Le thread principal observe TerminatedForFuel comme un Rust Err normal. Après le retour, l'adaptateur appelle cancel_terminate_execution afin que l'isolat soit réutilisable pour la requête suivante.

Cela donne un plafond d'horloge murale approximatif, et non un décompte d'instructions déterministe. Un script qui entre dans une boucle serrée sera tué dans un intervalle de surveillance (~ 50 ms par défaut), indépendamment de ce qu'il fait. Un script qui bloque l'analyse JSON d'une entrée pathologique sera également supprimé, mais dans la mesure du possible.

La mémoire est limitée par le mécanisme CreateParams::heap_limits standard de V8 : le dépassement de max_heap_bytes tue l'isolat via le rappel de limite proche du tas. Voir Différences connues ci-dessous pour connaître les mises en garde actuelles concernant ce bouton.

Modèle de threads#

Chaque V8Plugin possède exactement un isolat, épinglé à un thread de travail dédié créé au moment de la construction du plugin. Les appels depuis le chemin de requête sont acheminés via un canal mpsc : le thread appelant envoie une structure Request { method, arg_json, reply } et bloque sur le canal de réponse jusqu'à ce que le travailleur renvoie le résultat JSON.

Pourquoi ne pas exécuter l'isolat sur le thread appelant ? Deux raisons :

  1. Affinité de fil. V8 nécessite qu'un isolat soit saisi à partir du même thread du système d'exploitation qui l'a créé (ou explicitement verrouillé/déverrouillé entre les threads, ce que nous ne faisons pas). Lier l’isolat à un travailleur à longue durée de vie est le moyen le plus simple de conserver cet invariant.
  2. Découplage du chien de garde. L’appel request_interrupt du chien de garde nécessite un IsolateHandle stable. Un thread de travail épinglé + un thread de surveillance ayant fui signifie que le handle est facilement accessible depuis le chien de garde pendant toute la durée de vie du plugin.

V8PluginPool se trouve au-dessus et génère préalablement les instances size. Les acquisitions sont suivies pour les assertions de test afin que la suite d'intégration puisse vérifier la réutilisation au fil des appels.

Exemple complet : Garde de jeton du porteur#

javascript
// plugin.js
({
  _config: null,

  on_server_start(config_json) {
    this._config = JSON.parse(config_json);
    bext.log("info", `bearer-guard: protecting ${this._config.prefixes.join(", ")}`);
    return null;
  },

  on_request(ctx_json) {
    const ctx = JSON.parse(ctx_json);
    const protectedPath = this._config.prefixes.some((p) => ctx.path.startsWith(p));
    if (!protectedPath) {
      return bext.middleware_continue();
    }

    const authHeader = ctx.headers.find(([k]) => k.toLowerCase() === "authorization");
    if (!authHeader || !authHeader[1].startsWith("Bearer ")) {
      bext.emit_metric("bearer_guard_denied", 1, JSON.stringify({ reason: "missing" }));
      return bext.middleware_respond(JSON.stringify({
        status: 401,
        headers: [["www-authenticate", "Bearer realm=api"]],
        body: "missing bearer token",
      }));
    }

    const token = authHeader[1].slice("Bearer ".length);
    if (!this._config.allowed_tokens.includes(token)) {
      bext.emit_metric("bearer_guard_denied", 1, JSON.stringify({ reason: "invalid" }));
      return bext.middleware_respond(JSON.stringify({
        status: 403,
        headers: [],
        body: "invalid token",
      }));
    }

    return bext.middleware_continue();
  },
})

bext.config.toml correspondant :

toml
[plugins.bearer-guard]
sandbox = "v8"
path = "./plugins/bearer-guard/plugin.js"

[plugins.bearer-guard.config]
prefixes = ["/api/", "/admin/"]
allowed_tokens = ["tok_live_xxx", "tok_live_yyy"]

[plugins.bearer-guard.sandbox.limits]
max_heap_bytes = 33554432
fuel_per_call = 500
tick_interval_ms = 25

Quand choisir V8, QuickJS ou WASM#

Besoin Prendre
Petite empreinte, des dizaines de plugins par serveur QuickJS
JavaScript sur le chemin chaud, budget <1 ms V8
Langages non-JS (Rust, Go, AssemblyScript) WASM
Exécution déterministe, carburant vérifiable WASM
Partagez un environnement d'exécution avec React SSR V8
Isolation stricte de la mémoire avec un bac à sable au niveau du système d'exploitation nsjail

Le niveau V8 est délibérément l'option JS la plus lourde - optez pour cette option lorsque vous utilisez déjà le SSR basé sur V8 de bext et que vous souhaitez également amortir le coût du moteur sur la surface de votre plugin.

Différences connues#

Le niveau V8 est un runtime de plugin de première classe, mais il ne remplace pas encore le niveau wasmtime. La liste faisant autorité des lacunes (sémantique approximative du carburant, liaisons de capacités E1/E2 différées, fuite du thread de travail et modèle de test d'intégration unique que nous utilisons pour contourner un bogue du faisceau de tests rusty_v8 v130) est conservée à côté de la caisse à crates/bext-plugin-v8/KNOWN_DIFFERENCES.md. Lisez-le avant de déployer un plugin V8 en production.

Attention

L'application du carburant du niveau V8 est approximative, et non déterministe. Une boucle serrée est supprimée dans un intervalle de surveillance (~ 50 ms par défaut), et non selon un nombre d'instructions exact. Si vous avez besoin de limites de carburant déterministes, utilisez le Niveau WASM avec le budget de carburant par instruction de wasmtime.

Conseil

Les isolats du plugin V8 sont épinglés sur des threads de travail dédiés pendant toute la durée de vie du processus. Le coût de démarrage à froid (~ 15 ms par isolat) est payé une fois au démarrage du serveur, et non par requête. Ainsi, pool.size contrôle le nombre d'invocations simultanées de plugins qui peuvent avoir lieu sans mise en file d'attente.

En rapport#

  • Présentation du système de plugins — niveaux sandbox, types de fonctionnalités et pile de priorités middleware
  • Plugins QuickJS — alternative JS plus légère pour les petits plugins
  • Plugins WASM — carburant déterministe, langages compilés, pas de surcharge de thread de travail
  • Plugins Lua — scripts rechargeables à chaud avec une fraction du coût de la mémoire V8
  • Plugins nsjail — Isolation au niveau du système d'exploitation lorsque les sandbox en cours de processus ne suffisent pas
  • Piscine de rendu V8 — comment fonctionne le moteur V8 partagé de bext pour SSR
Modifier cette page ↗Besoin d’aide ? ↗
Rechercher dans la documentation

Saisissez un sujet, une commande ou une question.