rudak/js-injector

Symfony bundle to inject PHP constants into a JavaScript file

Maintainers

Package info

github.com/rudak/JsInjector

Type:symfony-bundle

pkg:composer/rudak/js-injector

Transparency log

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.1.1 2026-08-01 19:43 UTC

This package is auto-updated.

Last update: 2026-08-01 19:57:33 UTC


README

CI

Injectez des constantes PHP dans le navigateur, sans template échappé de force et sans appeler votre backend depuis le JavaScript.

JsInjector est un bundle Symfony + une bibliothèque JavaScript qui fait une seule chose : transformer vos valeurs serveur en variables JavaScript prêtes à l'emploi, de façon sûre, prévisible et testable.

Pourquoi utiliser JsInjector ?

Le problème

Vous avez des valeurs qui n'existent que côté serveur : URL d'API, identifiants d'environnement, feature flags, locales, timeouts, limites de pagination...

Sans outil, vous vous retrouvez à :

<!-- a) du inline JavaScript qui fuite partout -->
<script>
  var API_URL = 'https://api.example.com';      // dupliqué à la main
  var DEBUG = true;                              // ...et vite désynchronisé
</script>

<!-- b) des tags JSON que vous devez parser vous-même -->
<script id="config" type="application/json">
  {"API_URL": "https://api.example.com", "DEBUG": true}
</script>
<script>/* 30 lignes pour lire + injecter ça à la main */</script>

<!-- c) du data-attribute : chaque valeur récupérée par querySelector -->
<div data-api-url="..."></div>

Toutes ces approches ont les mêmes défauts : pas de validation, pas de typage, des données dupliquées entre PHP et JS, et des failles XSS dès que la moindre valeur contient </script> ou une apostrophe.

La solution

Avec JsInjector, la chaîne est unique et vérifiée :

Service PHP (provider)
        │  implémente HarvesterInterface
        │  taggé `rudak.injector`
        ▼
Collecte (ValuesHarvester) ──▶ validation des noms de variables
        ▼
Commande `rudak:generate:js`
        ▼
injection.js (fichier statique, sûr, versionnable)
        │  var { API_URL, DEBUG } = INJECTED_VALUES;
        ▼
votre code JS : console.log(API_URL)   // prêt à l'emploi

Pourquoi c'est mieux :

Inline / data-attributes JsInjector
Source de vérité dupliquée PHP + HTML un seul provider PHP
Validation des noms aucune identifiants JS + mots réservés (PHP et JS)
Sécurité XSS possible littéral JSON échappé (</script>)
Typage aucun types affichés à la génération
Testabilité impossible générateur 100 % testé (PHPUnit / Vitest)
Compat navigateur dépend de la méthode moderne et propre

Et le même moteur existe côté client : inject() fait la même chose à l'exécution, par exemple pour des valeurs reçues d'une API.

Installation

PHP (côté Symfony)

composer require rudak/js-injector

Prérequis : PHP 8.2+ et Symfony 6.4 ou 7.x. Le bundle s'enregistre automatiquement (Flex).

JavaScript (bibliothèque standalone)

npm install js-injector

La bibliothèque est disponible en ESM (import) et en CommonJS (require), avec les types TypeScript inclus.

Utilisation rapide

1. Créez un provider de valeurs

Un provider est n'importe quel service qui implémente HarvesterInterface :

// src/Service/FrontendConfigProvider.php
use Rudak\JsInjector\Harvester\HarvesterInterface;

class FrontendConfigProvider implements HarvesterInterface
{
    public function getValues(): array
    {
        return [
            'API_URL' => 'https://api.example.com',
            'DEBUG'   => $_ENV['APP_ENV'] === 'dev',
            'LOCALE'  => 'fr_FR',
        ];
    }
}

2. Taguez le service

# config/services.yaml
services:
    App\Service\FrontendConfigProvider:
        tags: ['rudak.injector']

Le tag est tout ce qui compte : JsInjector collecte automatiquement tous les services taggés, peu importe leur nombre.

Pour des valeurs fraîches à chaque rechargement (données dynamiques), utilisez le tag rudak.injector.dynamic — voir Injection dynamique ci-dessous.

3. Générer le fichier JS

php bin/console rudak:generate:js

Le fichier public/bundles/rudakInjection/injection.js est créé. Par défaut, c'est un module ES :

// generated by rudak:generate:js
export default {"API_URL":"https:\/\/api.example.com","DEBUG":true,"LOCALE":"fr_FR"};

La console affiche aussi un tableau récapitulatif des variables et de leurs types :

 --------------  -------
  variable name   type
 --------------  -------
  API_URL         string
  DEBUG           boolean
  LOCALE          string
 --------------  -------

4. Inclure et utiliser

Avec le format module (défaut) — import ES moderne, les valeurs ne polluent pas le scope global :

<!-- base.html.twig : le fichier est servi en module, pas de scope global -->
<script type="module" src="{{ asset('bundles/rudakInjection/injection.js') }}"></script>
// app.js
import config from '/bundles/rudakInjection/injection.js';

console.log(config.API_URL); // 'https://api.example.com'
console.log(config.DEBUG);   // true

Avec le format globals — script classique, les valeurs deviennent des variables globales :

<script src="{{ asset('bundles/rudakInjection/injection.js') }}"></script>
// app.js
console.log(API_URL);   // 'https://api.example.com'
console.log(DEBUG);     // true
if (DEBUG) { /* ... */ }

Les variables sont de vraies variables globales : pas besoin de window. ni de parseur.

Configuration du bundle

# config/packages/js_injector.yaml
js_injector:
    # Chemin du fichier généré, relatif au répertoire du projet.
    output_path: 'public/bundles/rudakInjection/injection.js'

    # Format du fichier généré : 'module' (défaut, import ES) ou 'globals' (script classique).
    format: 'module'

    # Optionnel : regroupe toutes les valeurs sous un seul namespace.
    namespace: 'MyApp'

Format module (défaut)

Le fichier est un module ES : export default quand aucun namespace n'est défini, export const <namespace> sinon. Il se consomme avec un import moderne — c'est la manière recommandée d'injecter des valeurs dans une application utilisant déjà un bundler (Vite, Webpack, Rollup, ...).

// generated by rudak:generate:js
export default {"API_URL":"https:\/\/api.example.com","DEBUG":true};
import config from '/bundles/rudakInjection/injection.js';
console.log(config.API_URL); // 'https://api.example.com'

Format globals

Le comportement historique : script classique, les valeurs deviennent des variables globales (ou une seule variable quand namespace est défini), sans import nécessaire.

// generated by rudak:generate:js
var INJECTED_VALUES = {"API_URL":"https:\/\/api.example.com","DEBUG":true};
// creating 2 variable(s)
var { API_URL, DEBUG } = INJECTED_VALUES;

Mode namespace

Avec namespace: 'MyApp', les valeurs sont regroupées sous une seule variable (ou un seul export nommé) :

En module :

// generated by rudak:generate:js
export const MyApp = {"API_URL":"https:\/\/api.example.com","DEBUG":true};
import { MyApp } from '/bundles/rudakInjection/injection.js';
console.log(MyApp.API_URL); // 'https://api.example.com'

En globals :

// generated by rudak:generate:js
var MyApp = {"API_URL":"https:\/\/api.example.com","DEBUG":true};
console.log(MyApp.API_URL); // 'https://api.example.com'

Recommandé dès que vous injectez plus de quelques valeurs, ou si vous voulez un contrat clair entre PHP et JS.

Injection dynamique

La commande rudak:generate:js fige les valeurs dans un fichier : parfait pour une configuration stable. Mais pour des données qui changent à chaque requête (utilisateur connecté, A/B testing, feature flags, stats en temps réel...), les valeurs doivent être recalculées à chaque rechargement de page.

JsInjector propose un second canal, branché au niveau du kernel Symfony, qui réutilise la même interface.

1. Taguez le provider comme dynamique

# config/services.yaml
services:
    App\Service\UserContextProvider:
        tags:
            # valeurs recalculées à chaque requête
            # 'channel' identifie ce jeu de données : il servira à choisir
            # quelles données injecter sur quelle page.
            - { name: 'rudak.injector.dynamic', channel: 'user_context' }

    App\Service\FeatureFlagsProvider:
        tags:
            - { name: 'rudak.injector.dynamic', channel: 'feature_flags' }

Le provider implémente la même HarvesterInterface, et il est exclu du fichier statique généré par la commande. Le tag channel est une simple étiquette de données (comme user_context, feature_flags, stats) : vous la réutilisez dans l'attribut pour choisir ce que la page reçoit. Sans channel, le provider est référencé par son id de service.

2. Activez et choisissez les pages

L'injection dynamique est opt-in : un switch global (dynamic_enabled, défaut false) pour l'activer, puis un attribut par page pour dire quelles données la page reçoit.

# config/packages/js_injector.yaml
js_injector:
    dynamic_enabled: true   # active le listener kernel.response
// src/Controller/AccountController.php
use Rudak\JsInjector\Attribute\JsInjectorDynamic;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

final class AccountController extends AbstractController
{
    // Cette action reçoit les données du channel 'user_context'.
    #[JsInjectorDynamic('user_context')]
    public function account(): Response { /* ... */ }

    // Cette action reçoit les données de 'user_context' ET 'feature_flags'.
    #[JsInjectorDynamic(['user_context', 'feature_flags'])]
    public function dashboard(): Response { /* ... */ }

    // Cette page ne reçoit rien de dynamique.
    public function home(): Response { /* ... */ }
}

Variantes : #[JsInjectorDynamic] sans valeur récolte tous les providers dynamiques ; posé sur la classe du contrôleur, l'attribut s'applique à toutes ses actions (un attribut posé sur l'action a priorité).

3. Que se passe-t-il sur une page marquée ?

Le DynamicValuesListener (sur kernel.response) agrège les providers dynamiques du channel demandé et injecte leurs valeurs dans la réponse HTML, juste avant </body> :

<script type="application/json" id="js-injector-dynamic">{"USER_ID":42,"CURRENT_LOCALE":"fr_FR","FEATURE_FLAGS":{"chat":true}}</script>
  • données fraîches à chaque chargement (recalculées par le kernel) ;
  • tag application/json inerte : le contenu n'est jamais exécuté, pas de risque XSS ni de souci CSP ;
  • rien n'est injecté si aucun provider dynamique ne correspond au channel demandé ;
  • les pages sans attribut ne sont jamais touchées (aucun tag, aucune charge).

4. Debug : voir les channels et leurs données

php bin/console rudak:debug:injection

Liste les channels dynamiques (avec leurs valeurs) et les providers statiques :

INJECTOR PROVIDERS
==================

Dynamic channels (tag: rudak.injector.dynamic)
 ----------------  ---------------------------  ------------------------------------
  channel           provider                    data
 ----------------  ---------------------------  ------------------------------------
  user_context      App\Service\UserContext..   USER_ID: 42, ROLE: admin
  feature_flags     App\Service\FeatureFlags..  CHAT_ENABLED: true
 ----------------  ---------------------------  ------------------------------------

Static providers (tag: rudak.injector, written to the generated file)
 ---------------------------  -------------------------------
  provider                    data
 ---------------------------  -------------------------------
  App\Service\FrontendConfig  API_URL: https://api.example.com
 ---------------------------  -------------------------------

5. Côté client

import { injectFromDom } from 'js-injector';

const result = injectFromDom('#js-injector-dynamic');
// => { success: true, injected: ['USER_ID', 'CURRENT_LOCALE', 'FEATURE_FLAGS'], errors: [] }
// => null si le tag n'est pas présent dans la page

injectFromDom(selector, options) combine loadFromScriptTag + inject : même API (namespace, freeze, overwrite, target), voir la référence.

6. Injection automatique (optionnel)

Le tag JSON est inerte : rien n'est injecté tant que le client ne lit pas le tag. Si vous voulez une injection 100 % automatique à chaque chargement de page, la façon la plus simple est un petit module de bootstrap :

// assets/bootstrap-inject.js
import { injectFromDom } from 'js-injector';

// Le tag <script type="application/json" id="js-injector-dynamic"> est déjà
// présent dans le DOM quand ce module s'exécute : les modules sont différés
// par défaut, ils tournent après le parsing du document.
injectFromDom('#js-injector-dynamic');
<!-- base.html.twig -->
<script type="module" src="{{ asset('assets/bootstrap-inject.js') }}"></script>

Pourquoi ça fonctionne sans DOMContentLoaded ? <script type="module"> est toujours exécuté après le parsing du document. Or le tag JSON est inséré juste avant </body> : au moment où votre module tourne, il existe forcément. Appeler injectFromDom quand le tag est absent est sans danger (il retourne null), ce module peut donc être chargé sur toutes les pages, y compris celles sans données dynamiques.

Sans module (script classique) ? Utilisez DOMContentLoaded — un <script> placé dans le template s'exécute pendant le parsing, potentiellement avant l'insertion du tag par le listener :

<script>
  document.addEventListener('DOMContentLoaded', async () => {
    const { injectFromDom } = await import('/bundles/js-injector/index.esm.js');
    injectFromDom('#js-injector-dynamic');
  });
</script>

Note CSP : un <script> inline exige 'unsafe-inline' (ou un nonce) dans votre politique. Le module de bootstrap externe, lui, est compatible CSP.

Statique + dynamique en même temps : combinez le fichier généré par la commande et le tag dynamique dans le même bootstrap :

import { inject, injectFromDom } from 'js-injector';
import config from '/bundles/rudakInjection/injection.js'; // valeurs statiques

inject(config);                        // configuration stable
injectFromDom('#js-injector-dynamic'); // valeurs fraîches à chaque page

Configurer l'identifiant du tag

# config/packages/js_injector.yaml
js_injector:
    dynamic_enabled: true                        # active l'injection dynamique
    dynamic_script_tag_id: 'app-dynamic-values'  # défaut : 'js-injector-dynamic'

Résumé statique vs dynamique

Statique (rudak.injector) Dynamique (rudak.injector.dynamic)
Quand configuration stable données recalculées à chaque requête
Mécanisme commande rudak:generate:js dynamic_enabled: true + #[JsInjectorDynamic]DynamicValuesListener (kernel.response)
Périmètre toutes les pages qui importent le fichier uniquement les pages marquées (#[JsInjectorDynamic('channel')])
Sélection des données par channel (tag channel du provider)
Fraîcheur au moment de la commande à chaque chargement de page
Côté client <script src> / import du fichier injectFromDom('#js-injector-dynamic')
Debug rudak:debug:injection (statique + dynamique) rudak:debug:injection

Comment ça marche, en détail

Collecte

ValuesHarvester agrège les services taggés rudak.injector (injection de dépendances Symfony, !tagged_iterator) pour le fichier statique. DynamicValuesHarvester agrège les services taggés rudak.injector.dynamic, indexés par channel et consommés à chaque requête par DynamicValuesListener (kernel.response). Seuls les channels demandés par #[JsInjectorDynamic] sont récoltés pour la page courante.

Validation

Chaque clé est validée comme identifiant JavaScript : caractères autorisés, pas de mot réservé (class, await, return, ...). Un provider avec une clé invalide est signalé dans la console et ignoré — le reste des valeurs est quand même généré. La même liste de mots réservés est partagée côté client (js-src/utils/validation.js) pour une cohérence parfaite.

Génération

JsFileGenerator sérialise les valeurs en un littéral JSON :

  • insensible aux apostrophes et aux sauts de ligne (plus de JSON.parse('...') qui casse) ;
  • </script> et les caractères Unicode échappés → pas d'injection HTML ;
  • priorité : si deux providers déclarent la même clé, le premier l'emporte (l'ordre des services dans le tag compte) ;
  • deux formats : module (défaut, export default / export const <namespace>) et globals (var INJECTED_VALUES + destructuring), choisis via la configuration format.

Côté client

La bibliothèque reproduit exactement ce comportement à l'exécution, ce qui permet de tester le rendu, ou d'injecter des valeurs reçues dynamiquement.

API JavaScript

import {
  // Injection
  inject, injectFromJson, injectFromDom, generateInjectionCode, remove,
  // Chargement
  loadScript, loadJson, loadFromScriptTag,
  // Erreurs
  InjectionError, LoadError, TimeoutError, ValidationError,
} from 'js-injector';

inject(values, options)

Injecte des valeurs dans un contexte cible (par défaut globalThis).

// Variables globales
inject({ API_URL: 'https://api.example.com', DEBUG: true });

// Dans un namespace
inject({ timeout: 5000 }, { namespace: 'MyApp' });
console.log(window.MyApp.timeout); // 5000

// Sur une cible précise (tests, modules, workers)
const target = {};
inject({ foo: 1 }, { target });

Options :

Option Défaut Rôle
target globalThis objet receveur
namespace null regroupe les valeurs sous un objet
freeze false Object.freeze les objets injectés (valeurs en lecture seule)
overwrite true false → refuse d'écraser une valeur existante

Retour : { success, injected: string[], errors: Array<{key, error}> }.

const result = inject({ foo: 1 }, { overwrite: false });
if (!result.success) {
  console.error(result.errors); // [{ key: 'foo', error: 'Key "foo" already exists...' }]
}

injectFromJson(jsonString, options)

La même chose, depuis une chaîne JSON (utile après un fetch).

injectFromJson('{"foo": 1, "bar": "hello"}', { namespace: 'App' });

injectFromDom(selector, options)

Lit les valeurs d'un <script type="application/json"> présent dans la page et les injecte. C'est le pendant client de l'injection dynamique Symfony (voir plus haut) : combine loadFromScriptTag + inject.

// Le tag est injecté par le bundle à chaque chargement de page.
const result = injectFromDom('#js-injector-dynamic');

if (result !== null) {
  console.log(result.injected); // ['USER_ID', 'CURRENT_LOCALE', ...]
}

Retourne null si le tag n'existe pas dans la page.

generateInjectionCode(values, options)

Génère le code source que produirait la commande Symfony — utile pour comparer, débugger, ou générer un fichier sans PHP. Mêmes formats que le générateur PHP : module (défaut) ou globals.

// Module (défaut)
generateInjectionCode({ foo: 1 });
// "// generated by rudak:generate:js\nexport default {\"foo\":1};\n"

// Module + namespace → export nommé
generateInjectionCode({ foo: 1 }, { namespace: 'MyApp' });
// "// generated by rudak:generate:js\nexport const MyApp = {\"foo\":1};\n"

// Globals (comportement historique)
generateInjectionCode({ foo: 1 }, { format: 'globals' });
// "// generated by rudak:generate:js\nvar INJECTED_VALUES = {\"foo\":1};\n..."

Options :

Option Défaut Rôle
namespace null regroupe les valeurs sous un namespace (export nommé en module)
format 'module' 'module' ou 'globals'

remove(keys, options)

Retire des valeurs précédemment injectées.

remove(['API_URL', 'DEBUG']);              // global scope
remove(['timeout'], { namespace: 'MyApp' });

Chargement de ressources

// Charge un script externe avec timeout
await loadScript('https://cdn.example.com/lib.js', { timeout: 5000, defer: true });

// Charge du JSON avec timeout (AbortController + fetch)
const data = await loadJson('/api/config.json', { timeout: 3000 });

// Lit un <script type="application/json"> déjà présent dans la page
const config = loadFromScriptTag('#app-config');

Erreurs

Des classes d'erreur dédiées pour un catch précis :

try {
  await loadJson('/api/config.json');
} catch (error) {
  if (error instanceof TimeoutError)      console.error('trop lent !');
  else if (error instanceof LoadError)    console.error('impossible de charger');
  else                                    throw error;
}

Exemple complet

Un cas réel : un provider qui expose la configuration de l'appli, versionnée, et un client qui l'utilise.

// src/Service/AppConfigProvider.php
use Rudak\JsInjector\Harvester\HarvesterInterface;

class AppConfigProvider implements HarvesterInterface
{
    public function getValues(): array
    {
        return [
            'APP_NAME'     => $this->appName,          // 'Acme'
            'API_URL'      => $this->apiBaseUrl,       // 'https://api.acme.test'
            'APP_ENV'      => $this->environment,      // 'prod'
            'FEATURES'     => ['search' => true, 'chat' => false],
            'MAX_PAGE_SIZE' => 50,
        ];
    }
}
# config/services.yaml
services:
    App\Service\AppConfigProvider:
        arguments: ['%env(APP_NAME)%', '%env(API_BASE_URL)%', '%kernel.environment%']
        tags: ['rudak.injector']
# config/packages/js_injector.yaml
js_injector:
    format: 'module'   # import ES moderne (défaut)
    namespace: 'AppConfig'
php bin/console rudak:generate:js
// src/app.js
// Le fichier généré est un module ES : import simple, pas de scope global pollué.
import { AppConfig } from '/bundles/rudakInjection/injection.js';

const { API_URL, FEATURES, MAX_PAGE_SIZE } = AppConfig;

if (FEATURES.search) {
  const results = await fetch(`${API_URL}/search?limit=${MAX_PAGE_SIZE}`);
  // ...
}

Développer sur le projet

# JavaScript
npm install
npm test              # Vitest (86 tests)
npm run lint          # ESLint (flat config)
npm run build         # Rollup → dist/ (ESM + CJS)
npm run test:coverage # couverture (~96 %)

# PHP
composer install
vendor/bin/phpunit                    # tests (44 tests)
vendor/bin/phpstan analyse            # analyse statique, niveau 6
vendor/bin/php-cs-fixer fix --dry-run # style de code Symfony

La CI GitHub Actions exécute tout cela sur Node 18/20 et PHP 8.2/8.3/8.4.

Structure du dépôt

├── src/                  # Bundle Symfony
│   ├── Command/          #   rudak:generate:js
│   ├── DependencyInjection/  # extension + configuration
│   ├── EventListener/    #   DynamicValuesListener (kernel.response)
│   ├── Generator/        #   JsFileGenerator (le moteur)
│   ├── Harvester/        #   interface + collecteurs tagués (statique/dynamique)
│   ├── Helper/           #   ValuesChecker, VariableTypeHelper
│   ├── Validator/        #   VariableNameValidator (miroir JS)
│   └── Resources/config/ #   services.yml
├── js-src/               # Bibliothèque JavaScript (ESM)
├── __tests__/            # Tests JS (Vitest)
├── tests/                # Tests PHP (PHPUnit)
└── types/                # Définitions TypeScript

Licence

Apache License 2.0