rudak / js-injector
Symfony bundle to inject PHP constants into a JavaScript file
Package info
Type:symfony-bundle
pkg:composer/rudak/js-injector
Requires
- php: ^8.2
- symfony/config: ^6.4 || ^7.0
- symfony/console: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/filesystem: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
- symfony/yaml: ^6.4 || ^7.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.49
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5 || ^11.0
README
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/jsoninerte : 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>) etglobals(var INJECTED_VALUES+ destructuring), choisis via la configurationformat.
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