discovery-media-player 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,6 +87,9 @@ forking it. A fix lands once and reaches every instance on its next deploy.
87
87
  - **Node / Express / Next.js** — same handler, mounted on a route
88
88
  - **Standalone** — `npm start`, or the Docker image
89
89
 
90
+ It reads `req.query` when the platform provides it (serverless, Express) and falls back to parsing
91
+ `req.url` when it does not — so a bare `http.createServer` works too, without a shim.
92
+
90
93
  See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the boundary, and
91
94
  [`docs/API.md`](docs/API.md) for the surface an integrator implements.
92
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
@@ -0,0 +1,90 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // L'AUDIENCE DOIT VRAIMENT ÊTRE BRANCHÉE SUR L'ÉTAT DU PRÉSENTATEUR.
4
+ //
5
+ // ⚠️ Ce test existe à cause d'une panne en production. `appliquerEtat` était défini dans un bloc
6
+ // de script, et l'abonnement à la diffusion écrit dans le SUIVANT — autre portée. La référence
7
+ // n'existait pas, la ReferenceError partait dans un try/catch muet, et l'audience n'avait aucun
8
+ // écouteur d'état. Invisible pendant tout le temps où une seconde voie (lecture de table) portait
9
+ // la page ; « les pages ne tournent plus » le jour où on l'a retirée pour fermer une fuite.
10
+ //
11
+ // La leçon : deux voies qui font la même chose ne se valident pas l'une l'autre. Tant que la
12
+ // seconde existait, aucun test ne pouvait dire laquelle marchait. On exécute donc la page.
13
+
14
+ // ⚠️ AVANT de requérir le gestionnaire : il déstructure ses dépendances au chargement, donc une
15
+ // substitution plus tardive n'aurait aucun effet — il partirait chercher la vraie base.
16
+ const PRESENTATION_ID = require.resolve("../presentations.js");
17
+ const vraiesPresentations = require("../presentations.js");
18
+ const PRESENTATION = {
19
+ slug: "Ab3-_xYz9012", doc_title: "Démo", file_name: "demo.pdf",
20
+ file_url: "https://exemple.supabase.co/storage/v1/object/public/resources/demo.pdf",
21
+ current_page: 1, active: true, updated_at: "2026-08-13T00:00:00.000Z",
22
+ };
23
+ require.cache[PRESENTATION_ID] = {
24
+ id: PRESENTATION_ID, filename: PRESENTATION_ID, loaded: true,
25
+ exports: { ...vraiesPresentations, getPresentation: async () => ({ ...PRESENTATION }), listMessages: async () => [] },
26
+ };
27
+
28
+ const player = require("../handler.js");
29
+
30
+ function contexteMinimal() {
31
+ return {
32
+ plugins: {}, has: () => false,
33
+ storage: { isAllowedUrl: () => true, async fetchFile() { return null; }, async put() {} },
34
+ db: { async request() { return []; }, async selectAll() { return []; } },
35
+ mail: { async send() {} },
36
+ identity: { async verifyToken() { return null; }, roleOf: () => "", isAdmin: () => false, async canManageShares() { return false; } },
37
+ limits: { async allow() { return true; } },
38
+ branding: { async logo() { return ""; }, name: "", poweredBy: "", loaderName: "", async forKey() { return null; }, title: (b) => b },
39
+ errors: { async capture() {} },
40
+ legal: { sourceUrl: "", legalUrl: "", privacyUrl: "", trackingNotice: "" },
41
+ config: { supabaseUrl: "https://exemple.supabase.co", supabasePublishableKey: "cle", mapsKey: "", extraFrameAncestors: [] },
42
+ };
43
+ }
44
+
45
+ async function pageAudience() {
46
+ player.init(contexteMinimal());
47
+ const res = {
48
+ statusCode: 0, headers: {}, body: "",
49
+ setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
50
+ end(b) { this.body = String(b == null ? "" : b); },
51
+ };
52
+ await player.handler({ method: "GET", headers: {}, socket: {}, query: { present: PRESENTATION.slug } }, res);
53
+ return res.body;
54
+ }
55
+
56
+ describe("page audience", () => {
57
+ it("expose vraiment le gestionnaire que la couche live doit brancher", async () => {
58
+ const html = await pageAudience();
59
+
60
+ // ⚠️ On EXÉCUTE la page. Le test statique ne pouvait pas voir le défaut : le code fautif
61
+ // contenait bien l'appel à onState — il référençait simplement un nom absent de cette
62
+ // portée-là. Seule l'exécution distingue « écrit » de « branché ».
63
+ window.supabase = { createClient: () => ({ channel: () => { const c = { on: () => c, subscribe: () => c }; return c; } }) };
64
+ for (const m of html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)) {
65
+ // `window.eval` et pas `new Function` : les scripts déclarent des `var` au niveau global,
66
+ // qu'une portée de fonction rendrait invisibles au bloc suivant — c'est exactement le
67
+ // genre de nuance de portée qui a produit le bug.
68
+ try { window.eval(m[1]); } catch { /* les scripts qui dépendent de pdf.js ne nous intéressent pas */ }
69
+ }
70
+
71
+ expect(typeof window.__presAppliquerEtat,
72
+ "le gestionnaire d'état n'est pas exposé : la couche live, définie dans un autre bloc, ne peut pas le brancher")
73
+ .toBe("function");
74
+ });
75
+
76
+ it("branche ce gestionnaire-là, et pas un nom hors de portée", async () => {
77
+ const html = await pageAudience();
78
+ expect(html).toContain("Live.onState(window.__presAppliquerEtat)");
79
+ });
80
+
81
+ // Il ne doit plus rester une seule voie qui exige une table lisible publiquement.
82
+ it("n'ouvre aucun abonnement à une table", async () => {
83
+ expect(await pageAudience()).not.toContain("postgres_changes");
84
+ });
85
+
86
+ // Un câblage raté doit se dire. C'est le silence, pas le bug, qui a coûté la journée.
87
+ it("crie si la couche live manque, au lieu d'avaler l'erreur", async () => {
88
+ expect(await pageAudience()).toContain("console.error('[present]");
89
+ });
90
+ });
@@ -0,0 +1,82 @@
1
+ // LE GESTIONNAIRE NE DOIT DÉPENDRE D'AUCUNE PLATEFORME.
2
+ //
3
+ // Le README promet « Vercel, Next.js, Express, ou le serveur HTTP de Node — le même gestionnaire ».
4
+ // C'était vrai à une convention près, jamais écrite : il lisait `req.query`, que les plateformes
5
+ // serverless et Express remplissent, mais qu'un serveur HTTP nu laisse indéfini.
6
+ //
7
+ // ⚠️ ET LE SYMPTÔME ÉTAIT LE PIRE POSSIBLE. Sans paramètres, la requête cherchait un partage nommé
8
+ // « rien » et rendait « Ce lien n'est plus valide ou a été révoqué ». Un intégrateur voyait un
9
+ // REFUS là où il lui manquait un branchement — l'inversion exacte qu'on passe notre temps à
10
+ // corriger. Signalé par un hôte qui montait le player sur http.createServer : chez lui, ça aurait
11
+ // marché en production par chance, pas par construction.
12
+
13
+ const player = require("../handler.js");
14
+
15
+ function contexteMinimal() {
16
+ return {
17
+ plugins: {}, has: () => false,
18
+ storage: { isAllowedUrl: () => false, async fetchFile() { return null; }, async put() {} },
19
+ db: { async request() { return []; }, async selectAll() { return []; } },
20
+ mail: { async send() {} },
21
+ identity: { async verifyToken() { return null; }, roleOf: () => "", isAdmin: () => false, async canManageShares() { return false; } },
22
+ limits: { async allow() { return true; } },
23
+ branding: { async logo() { return ""; }, name: "", poweredBy: "", loaderName: "", async forKey() { return null; }, title: (b) => b },
24
+ errors: { async capture() {} },
25
+ legal: { sourceUrl: "", legalUrl: "", privacyUrl: "", trackingNotice: "" },
26
+ config: { supabaseUrl: "", supabasePublishableKey: "", mapsKey: "", extraFrameAncestors: [] },
27
+ };
28
+ }
29
+
30
+ async function appel(req) {
31
+ player.init(contexteMinimal());
32
+ const res = {
33
+ statusCode: 0, headers: {}, body: "",
34
+ setHeader(k, v) { this.headers[k.toLowerCase()] = v; },
35
+ end(b) { this.body = String(b == null ? "" : b); },
36
+ };
37
+ await player.handler({ method: "GET", headers: {}, socket: {}, ...req }, res);
38
+ return res;
39
+ }
40
+
41
+ describe("d'où viennent les paramètres", () => {
42
+ // La convention serverless : la plateforme a déjà analysé l'URL.
43
+ it("utilise req.query quand la plateforme le fournit", async () => {
44
+ const res = await appel({ query: { contract: "1" } });
45
+ expect(res.statusCode).toBe(200);
46
+ expect(JSON.parse(res.body).contract).toBe(1);
47
+ });
48
+
49
+ // ⚠️ Le cas qui produisait « lien révoqué ». Un serveur HTTP nu ne remplit pas req.query.
50
+ it("les retrouve dans req.url quand elle ne le fournit pas", async () => {
51
+ const res = await appel({ url: "/api/doc?contract=1" });
52
+ expect(res.statusCode).toBe(200);
53
+ expect(JSON.parse(res.body).contract).toBe(1);
54
+ });
55
+
56
+ it("ne se laisse pas troubler par une URL absurde", async () => {
57
+ const res = await appel({ url: "pas une url du tout" });
58
+ expect(res.statusCode).toBe(400);
59
+ });
60
+ });
61
+
62
+ describe("quand rien n'est demandé", () => {
63
+ // ⚠️ LE POINT. Ni slug, ni présentation, ni aperçu : il n'y a rien à afficher, et ce n'est PAS
64
+ // un refus. Rendre la page de révocation envoyait l'intégrateur chercher un lien mort.
65
+ it("le dit, au lieu d'afficher « lien révoqué »", async () => {
66
+ const res = await appel({ query: {} });
67
+ expect(res.statusCode).toBe(400);
68
+ expect(res.body).toContain("Aucun document demandé");
69
+ expect(res.body).not.toContain("révoqué");
70
+ });
71
+
72
+ it("oriente vers la cause réelle : les paramètres de requête", async () => {
73
+ expect((await appel({ query: {} })).body).toMatch(/paramètres de requête/);
74
+ });
75
+
76
+ // Un slug inconnu, LUI, est bien un refus : la distinction doit rester nette dans les deux sens.
77
+ it("un slug inconnu reste un refus, pas une erreur de branchement", async () => {
78
+ const res = await appel({ query: { slug: "Inconnu-1234" } });
79
+ expect(res.statusCode).toBe(404);
80
+ expect(res.body).toContain("révoqué");
81
+ });
82
+ });
package/server/handler.js CHANGED
@@ -397,8 +397,8 @@ var Live=(function(){
397
397
  try{sb=window.supabase.createClient(LIVECFG.supaUrl,LIVECFG.supaKey,{realtime:{params:{eventsPerSecond:10}}});
398
398
  ch=sb.channel('plive-'+slug,{config:{presence:{key:(me.email||me.name||'x')+':'+MYID}}});
399
399
  ch.on('presence',{event:'sync'},function(){renderPres(ch.presenceState());});
400
- ch.on('postgres_changes',{event:'INSERT',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new&&addMsg(p.new))notifyMsg(p.new);});
401
- ch.on('postgres_changes',{event:'UPDATE',schema:'public',table:'doc_presentation_messages',filter:'slug=eq.'+slug},function(p){if(p&&p.new)updateMsg(p.new);});
400
+ // Plus d'abonnement à la table des messages : elle n'est plus publiée ni lisible
401
+ // publiquement. Tout passe par la diffusion, et l'historique par la route de chat.
402
402
  ch.on('broadcast',{event:'msg'},function(p){if(p&&p.payload&&addMsg(p.payload))notifyMsg(p.payload);});
403
403
  ch.on('broadcast',{event:'msg-upd'},function(p){if(p&&p.payload)updateMsg(p.payload);});
404
404
  ch.on('broadcast',{event:'typing'},function(p){onTyping(p&&p.payload);});
@@ -2016,6 +2016,12 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2016
2016
  // diffusion temps réel. La règle d'ordre (terminée > carte > changement de doc > page) et la
2017
2017
  // re-validation du contenu vivent dans le module, pas ici.
2018
2018
  var _etatVu='';
2019
+ // ⚠️ EXPOSÉ HORS DE CETTE FERMETURE, ET C'EST INDISPENSABLE. La couche Live est définie dans
2020
+ // le bloc de script SUIVANT : y appeler onState avec ce nom-ci référence une fonction qui
2021
+ // n'existe pas dans cette portée-là. L'exception partait dans un try/catch muet, donc
2022
+ // l'audience n'avait aucun écouteur de diffusion — invisible tant que la lecture de table
2023
+ // portait encore la page, puis « les pages ne tournent plus » le jour où on l'a retirée.
2024
+ window.__presAppliquerEtat = appliquerEtat;
2019
2025
  function appliquerEtat(row){
2020
2026
  if(!row) return;
2021
2027
  // La table et la diffusion portent la même vérité : sans cette garde, chaque changement de
@@ -2042,16 +2048,13 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2042
2048
  document.addEventListener('visibilitychange',function(){ if(document.visibilityState==='visible') relireEtat(); });
2043
2049
  window.addEventListener('online', relireEtat);
2044
2050
 
2045
- // Realtime : suivre la page courante + la fin de session (UPDATE de notre ligne).
2046
- try{
2047
- if(window.supabase && CFG.supaUrl && CFG.supaKey){
2048
- var sb=window.supabase.createClient(CFG.supaUrl, CFG.supaKey, {realtime:{params:{eventsPerSecond:5}}});
2049
- sb.channel('present-'+CFG.slug)
2050
- .on('postgres_changes',{event:'UPDATE',schema:'public',table:'doc_presentations',filter:'slug=eq.'+CFG.slug},function(payload){
2051
- appliquerEtat(payload && payload.new);
2052
- }).subscribe();
2053
- }
2054
- }catch(e){}
2051
+ // ⚠️ PLUS D'ABONNEMENT À LA TABLE ICI. Il exigeait une lecture publique de
2052
+ // de la table des présentations — donc, avec la clé publiable, TOUTES les présentations de
2053
+ // l'instance. L'état arrive par diffusion du présentateur, et la relecture d'état ci-dessus
2054
+ // rattrape les arrivées tardives et les retours d'onglet en interrogeant le serveur.
2055
+ //
2056
+ // Le laisser en place n'était pas neutre : il ouvrait un abonnement qui ne recevra jamais
2057
+ // rien, ce qui donne l'impression que le suivi est branché alors qu'il ne l'est pas.
2055
2058
  // Si on rejoint alors que le présentateur est DÉJÀ sur une carte / Street View → l'afficher dès que Map3DD est prêt.
2056
2059
  if(CFG.content && (CFG.content.kind==='map'||CFG.content.kind==='streetview')){ var _mi=setInterval(function(){ if(window.Map3DD){ clearInterval(_mi); if(CFG.content.kind==='streetview')Map3DD.enterSV(CFG.content,false); else Map3DD.enter(CFG.content,false); } },100); setTimeout(function(){ clearInterval(_mi); },8000); }
2057
2060
  })();
@@ -2067,7 +2070,16 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
2067
2070
  // L'état arrive maintenant par DEUX voies : la table (historique) et la diffusion du
2068
2071
  // présentateur (nouvelle). Les deux passent par le même filtre, qui ignore un état déjà
2069
2072
  // appliqué — recevoir deux fois la même chose ne doit pas re-rendre la page.
2070
- try{ if(window.Live) Live.onState(appliquerEtat); }catch(e){}
2073
+ // Par la référence exposée plus haut : le nom local n'existe pas dans cette portée-ci.
2074
+ try{
2075
+ if(!window.Live || !window.__presAppliquerEtat) throw new Error('couche live absente');
2076
+ Live.onState(window.__presAppliquerEtat);
2077
+ }catch(e){
2078
+ // PLUS DE SILENCE ICI. Ce try/catch a avalé une ReferenceError : l'audience n'avait aucun
2079
+ // ecouteur d'etat, ce qui ne se voyait pas tant qu'une seconde voie portait la page. Un
2080
+ // cablage rate doit se dire, meme si la page continue de vivre.
2081
+ console.error('[present] suivi de l etat non branche :', e && e.message);
2082
+ }
2071
2083
  // « Reprendre la main » : si le membre connecté (même origine) devient propriétaire de CETTE présentation
2072
2084
  // (après un transfert), on affiche un bouton pour ouvrir la visionneuse en pilotage — sinon on ne peut pas
2073
2085
  // piloter depuis la page audience.
@@ -2101,9 +2113,34 @@ async function readJsonBody(req) {
2101
2113
  });
2102
2114
  }
2103
2115
 
2116
+ /**
2117
+ * Paramètres de la requête, quelle que soit la plateforme.
2118
+ *
2119
+ * Le gestionnaire lit `req.query` — la convention des plateformes serverless (Vercel, Next.js) et
2120
+ * d'Express. Un serveur HTTP nu ne la remplit pas : `req.query` est alors `undefined`, et TOUT
2121
+ * paramètre disparaît.
2122
+ *
2123
+ * ⚠️ CE QUE ÇA DONNAIT, ET POURQUOI C'ÉTAIT LE PIRE DES SYMPTÔMES. Sans paramètres, la requête
2124
+ * partait chercher un partage nommé « rien », n'en trouvait pas, et affichait « Ce lien n'est plus
2125
+ * valide ou a été révoqué ». Un intégrateur voyait donc un REFUS là où il n'avait simplement pas
2126
+ * branché la plateforme. C'est exactement l'inversion qu'on passe notre temps à corriger : une
2127
+ * erreur de câblage ne doit jamais ressembler à une décision.
2128
+ *
2129
+ * Signalé par un hôte qui montait le player sur `http.createServer` : chez lui ça aurait marché en
2130
+ * production (Vercel remplit `req.query`) — par chance, pas par construction.
2131
+ */
2132
+ function parametres(req) {
2133
+ if (req.query && typeof req.query === "object") return req.query;
2134
+ try {
2135
+ return Object.fromEntries(new URL(req.url || "/", "http://interne").searchParams);
2136
+ } catch {
2137
+ return {};
2138
+ }
2139
+ }
2140
+
2104
2141
  async function handler(req, res) {
2105
2142
  try {
2106
- const q = req.query || {};
2143
+ const q = parametres(req);
2107
2144
  const slug = String(q.slug || "").trim();
2108
2145
 
2109
2146
  if (req.method === "POST") {
@@ -2484,6 +2521,17 @@ async function handler(req, res) {
2484
2521
  return;
2485
2522
  }
2486
2523
 
2524
+ // AUCUN DOCUMENT DEMANDÉ. Ni slug, ni présentation, ni aperçu, ni carte d'identité — il n'y a
2525
+ // rien à afficher, et ce n'est pas un refus. Le dire franchement évite qu'un intégrateur
2526
+ // cherche un lien révoqué là où il lui manque un paramètre.
2527
+ if (req.method === "GET" && !slug && !q.present && !q.preview && !q.contract) {
2528
+ res.statusCode = 400;
2529
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
2530
+ res.end("Aucun document demandé. Attendu : ?slug=… , ?present=… , ?preview=1 ou ?contract=1.\n" +
2531
+ "Si vous intégrez le player, vérifiez que la plateforme fournit les paramètres de requête.");
2532
+ return;
2533
+ }
2534
+
2487
2535
  // ── CARTE D'IDENTITÉ (`?contract=1`) ─────────────────────────────────────────────────────
2488
2536
  // La règle 4 du contrat demande à l'hôte d'épingler la version qu'il vise et de le VÉRIFIER.
2489
2537
  // Sans point d'interrogation, cette règle était une intention : un hôte ne pouvait pas écrire