diff --git a/bin/server.dart b/bin/server.dart index 638bb32..1a0112e 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -27,6 +27,7 @@ import 'middleware/auth_middleware.dart'; import 'middleware/security_middleware.dart'; import 'services/cleanup_service.dart'; import 'services/recording_scheduler.dart'; +import 'utils/asset_versioning.dart'; void main(List args) async { // Parse command line arguments @@ -293,14 +294,37 @@ void main(List args) async { listDirectories: false, ); + // Points d'entrée réécrits avec `?v=` sur leurs JS : voir + // asset_versioning.dart (un proxy qui cache les .js servait l'ancienne + // app et l'ancien lecteur après chaque déploiement). + final versionedEntryPoints = buildVersionedEntryPoints(webPath); + print('[Server] Assets versionnés : ${versionedEntryPoints.keys.join(', ')}'); + // Wrap static handler to enforce cache policies FutureOr staticHandler(Request request) async { + final path = request.url.path; + + final versioned = + versionedEntryPoints[path.isEmpty ? 'index.html' : path]; + if (versioned != null && request.method == 'GET') { + return Response.ok( + versioned, + headers: { + 'Content-Type': path.endsWith('.js') + ? 'text/javascript; charset=utf-8' + : 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + ); + } + final response = await baseStaticHandler(request); // Disable cache for entry points to ensure updates are seen immediately. // Vendored player libraries (hls.js/mpegts.js, ~750 KB) are pinned // versions: keep them cacheable or every player open re-downloads them. - final path = request.url.path; final isVendored = path.startsWith('vendor/'); if (!isVendored && (path.isEmpty || diff --git a/bin/test/asset_versioning_test.dart b/bin/test/asset_versioning_test.dart new file mode 100644 index 0000000..aca9d67 --- /dev/null +++ b/bin/test/asset_versioning_test.dart @@ -0,0 +1,47 @@ +import 'package:test/test.dart'; +import '../utils/asset_versioning.dart'; + +void main() { + group('versionAssetRefs', () { + test('versionne la balise script du lecteur', () { + expect( + versionAssetRefs( + '', + {'xf-player-core.js': 'abc123'}, + ), + '', + ); + }); + + test('versionne mainJsPath dans la config de build Flutter', () { + expect( + versionAssetRefs( + '{"compileTarget":"dart2js","mainJsPath":"main.dart.js"}', + {'main.dart.js': 'f00'}, + ), + '{"compileTarget":"dart2js","mainJsPath":"main.dart.js?v=f00"}', + ); + }); + + test('ne touche ni un nom qui ne fait que contenir l\'asset ni une ref déjà versionnée', () { + const html = '' + ''; + expect(versionAssetRefs(html, {'xf-player-core.js': 'new'}), html); + }); + + test('guillemets simples acceptés', () { + expect( + versionAssetRefs("", {'xf-player.css': '1'}), + "", + ); + }); + }); + + group('contentVersion', () { + test('stable pour un même contenu, différente sinon', () { + expect(contentVersion([1, 2, 3]), contentVersion([1, 2, 3])); + expect(contentVersion([1, 2, 3]), isNot(contentVersion([1, 2, 4]))); + expect(contentVersion([1, 2, 3]), hasLength(12)); + }); + }); +} diff --git a/bin/utils/asset_versioning.dart b/bin/utils/asset_versioning.dart new file mode 100644 index 0000000..178f8c9 --- /dev/null +++ b/bin/utils/asset_versioning.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +/// Versionnage des assets non hachés de la build web. +/// +/// POURQUOI : `main.dart.js`, `flutter_bootstrap.js` et `xf-player-core.js` +/// gardent le même nom d'une build à l'autre. Un reverse proxy qui met les +/// `.js` en cache (option « Cache Assets » de Nginx Proxy Manager, constatée +/// en prod : cache jusqu'à 12 h, même sur un rechargement forcé) servait donc +/// l'ANCIEN lecteur et l'ANCIENNE app avec le NOUVEAU serveur après chaque +/// déploiement. En ajoutant `?v=` aux références, +/// chaque version a sa propre URL : aucun cache intermédiaire ne peut la +/// confondre avec la précédente. + +/// Empreinte courte et stable de [bytes]. +String contentVersion(List bytes) => + md5.convert(bytes).toString().substring(0, 12); + +/// Ajoute `?v=` à chaque référence entre guillemets à un asset de +/// [versions] (`"main.dart.js"` → `"main.dart.js?v=…"`). +/// +/// Seules les références exactes sont réécrites : `vendor/x.js` ou une +/// référence déjà versionnée restent intactes. +String versionAssetRefs(String content, Map versions) { + var out = content; + versions.forEach((asset, version) { + final pattern = RegExp('(["\'])${RegExp.escape(asset)}\\1'); + out = out.replaceAllMapped( + pattern, + (m) => '${m[1]}$asset?v=$version${m[1]}', + ); + }); + return out; +} + +/// Contenus réécrits, indexés par chemin relatif (`index.html`…), prêts à +/// être servis à la place des fichiers d'origine. +/// +/// Calculé une fois au démarrage : la build web est figée dans l'image. +Map buildVersionedEntryPoints(String webPath) { + String? read(String name) { + final file = File('$webPath/$name'); + return file.existsSync() ? file.readAsStringSync() : null; + } + + String? versionOf(String name) { + final file = File('$webPath/$name'); + return file.existsSync() ? contentVersion(file.readAsBytesSync()) : null; + } + + final rewritten = {}; + + // Le bootstrap référence main.dart.js : sa version dépend donc de celle + // de main.dart.js, d'où une version calculée APRÈS réécriture. + final bootstrap = read('flutter_bootstrap.js'); + final mainVersion = versionOf('main.dart.js'); + if (bootstrap != null && mainVersion != null) { + final versioned = + versionAssetRefs(bootstrap, {'main.dart.js': mainVersion}); + rewritten['flutter_bootstrap.js'] = versioned; + final index = read('index.html'); + if (index != null) { + rewritten['index.html'] = versionAssetRefs(index, { + 'flutter_bootstrap.js': contentVersion(utf8.encode(versioned)), + }); + } + } + + final playerAssets = { + for (final name in const ['xf-player-core.js', 'xf-player.css']) + if (versionOf(name) case final v?) name: v, + }; + for (final page in const [ + 'player.html', + 'player_lite.html', + 'player_mobile.html', + ]) { + final html = read(page); + if (html != null) rewritten[page] = versionAssetRefs(html, playerAssets); + } + return rewritten; +}