discovery-media-player 0.1.139 → 0.1.142
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/bin/serve.js +39 -3
- package/docs/HOST-CONTRACT.md +152 -3
- package/docs/RETENTION.md +37 -0
- package/package.json +4 -2
- package/server/cache.js +11 -0
- package/server/erreurs-base.js +34 -1
- package/server/gabarit-agent.js +29 -4
- package/server/handler.js +64 -1
- package/server/mesures.js +206 -0
- package/server/presentations.js +1 -28
- package/server/reponses.js +32 -1
- package/server/retention.js +70 -4
- package/server/routes-agent.js +301 -56
- package/server/routes-direct.js +8 -8
- package/server/routes-liens.js +2 -2
- package/server/shares.js +174 -56
- package/supabase/init.sql +179 -4
- package/supabase/migrations/0020-mesures-bornees.sql +28 -16
- package/supabase/migrations/0021-empreintes-de-voix.sql +49 -0
- package/supabase/migrations/0022-statistiques-agregees.sql +156 -0
- package/supabase/migrations/0023-bornes-des-sessions-internes.sql +84 -0
- package/supabase/recensement-retention.sql +11 -1
package/bin/serve.js
CHANGED
|
@@ -108,8 +108,33 @@ async function pageAccueil(racine) {
|
|
|
108
108
|
</div></body></html>`;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* ⚠️ LE TRAITEMENT VIT DANS UNE FONCTION, ET C'EST L'ÉCOUTEUR QUI RATTRAPE. Ce corps était passé
|
|
113
|
+
* directement à `createServer` en fonction `async` : `http` n'attend pas la promesse rendue, donc
|
|
114
|
+
* TOUTE exception levée hors du `try` du bas — analyse de l'URL, page d'accueil, lecture du corps —
|
|
115
|
+
* devenait un rejet non géré, et Node sort du processus sur un rejet non géré. UNE SEULE REQUÊTE
|
|
116
|
+
* ANONYME ARRÊTAIT DONC LE SERVEUR : constaté sur la v0.1.139, code de sortie 1, port fermé, les
|
|
117
|
+
* requêtes suivantes refusées. Le `try` existant ne couvrait que `player.handler`.
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ ET LA BASE DE L'URL EST FIXE — L'EN-TÊTE `Host` DU CLIENT N'EN EST PAS UNE. Écrire
|
|
120
|
+
* `new URL(req.url, "http://" + host)` fait entrer une chaîne choisie par l'appelant dans un
|
|
121
|
+
* analyseur qui jette : `Host: [` donne la base `http://[`, invalide, exception. Or rien ici ne lit
|
|
122
|
+
* l'hôte — seuls `pathname` et `searchParams` servent : il n'apportait QUE sa panne.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ LA BONNE FORME ÉTAIT DÉJÀ ÉCRITE À CÔTÉ, ET CELLE-CI ÉTAIT LA COPIE DIVERGENTE.
|
|
125
|
+
* `server/handler.js` analyse depuis une base interne fixe, et le lien des courriels de re-partage
|
|
126
|
+
* a quitté `req.headers.host` pour `PLAYER_PUBLIC_URL` (cf. `server/__tests__/originePublique.test.js`).
|
|
127
|
+
* Deux fois la même leçon : une origine publique se DÉCLARE, elle ne se devine pas dans un en-tête.
|
|
128
|
+
*/
|
|
129
|
+
async function servir(req, res) {
|
|
130
|
+
let url;
|
|
131
|
+
try {
|
|
132
|
+
url = new URL(req.url || "/", "http://interne");
|
|
133
|
+
} catch {
|
|
134
|
+
// La cible de requête elle-même est malformée : c'est le client qui a tort, pas le serveur.
|
|
135
|
+
player.refuserEnTexte(res, 400, "Bad request");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
113
138
|
|
|
114
139
|
// Point de santé : un orchestrateur doit pouvoir savoir si le processus répond sans ouvrir un
|
|
115
140
|
// document ni toucher la base.
|
|
@@ -165,6 +190,17 @@ const serveur = http.createServer(async (req, res) => {
|
|
|
165
190
|
// de l'écriture ne peut plus rien poser, et tenter de le faire jetterait dans le rattrapage.
|
|
166
191
|
player.refuserEnTexte(res, 500, "Erreur");
|
|
167
192
|
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const serveur = http.createServer((req, res) => {
|
|
196
|
+
// ⚠️ L'ÉCOUTEUR N'EST PAS `async` : il RATTRAPE. `void servir(...).catch(...)` est précisément ce
|
|
197
|
+
// qui transforme un rejet fatal en réponse. Le `try` autour de `player.handler` reste — il
|
|
198
|
+
// distingue une erreur du player d'une erreur du serveur — ; celui-ci est le filet de tout le
|
|
199
|
+
// reste, y compris de ce que personne n'a encore ajouté au-dessus.
|
|
200
|
+
void servir(req, res).catch((error) => {
|
|
201
|
+
console.error("[player] erreur non rattrapée", error);
|
|
202
|
+
player.refuserEnTexte(res, 500, "Erreur");
|
|
203
|
+
});
|
|
168
204
|
});
|
|
169
205
|
|
|
170
206
|
/** Corps JSON, borné. Un corps sans fin est une façon peu coûteuse de faire tomber un serveur. */
|
|
@@ -250,4 +286,4 @@ if (require.main === module) {
|
|
|
250
286
|
});
|
|
251
287
|
}
|
|
252
288
|
|
|
253
|
-
module.exports = { serveur, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
|
|
289
|
+
module.exports = { serveur, servir, versParametres, pageAccueil, __arreterProprement: arreterProprement, DELAI_ARRET_MS };
|
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -41,6 +41,7 @@ need.
|
|
|
41
41
|
"presenceDurcissement": "inconnu",
|
|
42
42
|
"presenceFusion": "inconnu",
|
|
43
43
|
"lectureSaturee": { "total": 0, "fenetreS": 0, "derniereIlYaS": null },
|
|
44
|
+
"mesures": { "fenetreS": 0, "seauxMs": [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000], "routes": {}, "base": { "n": 0 }, "statuts": { "ok": 0, "refus4xx": 0, "debit429": 0, "occupe503": 0, "erreur5xx": 0 }, "memoireMio": { "rss": 0, "heap": 0, "tampons": 0 }, "boucleMs": { "n": 0, "moyen": null, "p99": null, "resolutionMs": 20 } },
|
|
44
45
|
"retentionSweep": false,
|
|
45
46
|
"hostShare": true,
|
|
46
47
|
"hostMail": true,
|
|
@@ -53,6 +54,21 @@ need.
|
|
|
53
54
|
never by order. `plugins` lets you refuse to start when you depend on an optional module this
|
|
54
55
|
instance does not have.
|
|
55
56
|
|
|
57
|
+
⚠️ **`ctx.has(name)` is that same question in code — and it is documented here because it was, until
|
|
58
|
+
27/08, an accident.** The injected context carries it: `has(name)` answers whether the named plugin
|
|
59
|
+
is present, the same truth the `plugins` field above reports over HTTP. Supply it as
|
|
60
|
+
`has: (name) => !!plugins[name]`; the standalone context returns `false` for everything, having no
|
|
61
|
+
plugins at all.
|
|
62
|
+
|
|
63
|
+
**As measured on 27/08, nothing in `server/` called it** — that is a reading, not an omission of
|
|
64
|
+
this paragraph, and it is why implementing it buys you nothing immediately and skipping it costs
|
|
65
|
+
you nothing. It is written down for the opposite reason: on that day one host was found to
|
|
66
|
+
implement it *correctly and without knowing*, because the type declared it, while the shape lived
|
|
67
|
+
in 57 test fixtures and, **until this paragraph existed**, in no document at all. A seam that
|
|
68
|
+
exists, works, and is written nowhere is one rename away from being deleted as dead — and it would
|
|
69
|
+
not have been dead. If a future feature needs to ask *"does this host have that plugin?"*, this is
|
|
70
|
+
the spelling, and there should not be a second one.
|
|
71
|
+
|
|
56
72
|
⚠️ **`runtime` is the only way to see what the player is actually running on.** `nodeRequired` is
|
|
57
73
|
the floor the package declares, `node` is what the process reports — two numbers, no verdict:
|
|
58
74
|
compare them with your own semver rather than trusting a field we compute for you.
|
|
@@ -92,6 +108,43 @@ The three `presence*` fields report what the host has **observed**, not what it
|
|
|
92
108
|
| `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
|
|
93
109
|
| `presenceFusion` | `actif` (a heartbeat used the fused contract — one round trip instead of two), `degrade` (migration 0019 is missing: heartbeats cost 3 round trips instead of 2, nothing breaks), `inconnu` (no heartbeat served in this process). Same three states, same trap, same reading rule as the row above |
|
|
94
110
|
|
|
111
|
+
### `mesures` — what this instance has actually lived through
|
|
112
|
+
|
|
113
|
+
`lectureSaturee` (below) answers exactly one question. *Is a route slow? which ones? us or the
|
|
114
|
+
database? how many 5xx? is the event loop slipping?* had **no observable answer at all** — and
|
|
115
|
+
deciding to optimise without them is guessing. Both integrating hosts confirmed they cannot produce
|
|
116
|
+
these numbers from their side.
|
|
117
|
+
|
|
118
|
+
| key | meaning |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `fenetreS` | seconds this process has been running — **the window every total below was counted over** |
|
|
121
|
+
| `seauxMs` | the bucket ladder the percentiles are read off, published **with** the numbers |
|
|
122
|
+
| `routes` | one entry per family of work — `document`, `presentation`, `action`, `fichier`, `carte`, `autre`. Families absent from the object were never exercised in this process |
|
|
123
|
+
| `base` | the same shape, for calls through the `db` capability **you** supply — measured at the seam, so it covers every call, including ones nobody has written yet |
|
|
124
|
+
| `statuts` | responses by class: `ok` (<400), `refus4xx`, `debit429`, `occupe503`, `erreur5xx` |
|
|
125
|
+
| `memoireMio` | `rss`, `heap` (heap used), `tampons` (`arrayBuffers`) in MiB, read at the moment of the request |
|
|
126
|
+
| `boucleMs` | event-loop **delay** — `moyen` and `p99` in ms, with `n` samples and the sampler's `resolutionMs` |
|
|
127
|
+
|
|
128
|
+
⚠️ **A percentile over buckets is a bound, not a value.** `p95sousMs: 250` reads *"95% of calls
|
|
129
|
+
under 250 ms"* — never *"the 95th is 250 ms"*. That is why the key is named `sousMs`, and why
|
|
130
|
+
`seauxMs` ships alongside: without the ladder you cannot judge how precise the number you are
|
|
131
|
+
reading is. `null` means *past the top of the ladder* (over 10 s), which is itself the answer.
|
|
132
|
+
|
|
133
|
+
⚠️ **`n: 0` is not `0 ms`.** A family that was never exercised reports `{ "n": 0 }` and nothing
|
|
134
|
+
else, and `boucleMs` with no samples reports `moyen: null` — not a zero that would read as *healthy*.
|
|
135
|
+
|
|
136
|
+
⚠️ **`boucleMs` is the delay, not the interval.** The sampler observes how long its own timer
|
|
137
|
+
actually took, which at rest equals its resolution; the resolution is subtracted, so an idle
|
|
138
|
+
instance reports about `0` rather than a permanent `20` that would send you hunting a fault that
|
|
139
|
+
does not exist.
|
|
140
|
+
|
|
141
|
+
⚠️ **No slug, no address, no text.** These are counters and durations. Nothing here names a visitor,
|
|
142
|
+
a document or a presentation — which is what makes it publishable on a card you read without
|
|
143
|
+
ceremony.
|
|
144
|
+
|
|
145
|
+
⚠️ **Process-local, and reset by every deployment**, exactly like `lectureSaturee` below. Behind a
|
|
146
|
+
load balancer this is the instance that answered, not your deployment. Aggregating is your job.
|
|
147
|
+
|
|
95
148
|
### `lectureSaturee` — what this instance actually refused
|
|
96
149
|
|
|
97
150
|
The read cache groups concurrent requests for the same presentation state and admits a bounded
|
|
@@ -179,10 +232,20 @@ it. `verdict` is then one of:
|
|
|
179
232
|
|
|
180
233
|
| verdict | meaning |
|
|
181
234
|
|---|---|
|
|
182
|
-
| `non-sonde` | nothing asked yet — **not** *nothing missing
|
|
183
|
-
| `partiel` | some
|
|
235
|
+
| `non-sonde` | nothing asked yet — **not** *nothing missing*, and **process-local**: this process has looked at nothing; another instance may have looked at everything |
|
|
236
|
+
| `partiel` | some checked, none of them missing — the probe is **lazy**: a column is inspected only once something touches it, so this is *how much has been exercised*, never *how much exists* |
|
|
184
237
|
| `complet` | all checked, all present |
|
|
185
238
|
| `incomplet` | at least one is missing — `manquant` names the file and the sleeping feature |
|
|
239
|
+
| `indetermine` | the database did not answer; this measurement did not happen |
|
|
240
|
+
|
|
241
|
+
⚠️ **The first two rows are one trap, and it fires on every deploy.** Because the probe is lazy and
|
|
242
|
+
lives in the process, a fresh instance answers `non-sonde`, then `partiel`, then `complet` as
|
|
243
|
+
traffic exercises columns — on a database that never changed. Measured at an integrating host on
|
|
244
|
+
27/08, on one unchanged base: `sondees=9 complet` on the old instance, `sondees=0 non-sonde` right
|
|
245
|
+
after the deploy, `sondees=1 partiel` sixty-seven minutes later. They looked three times before
|
|
246
|
+
concluding, because a neighbouring field had already taught them to distrust that zero — and they
|
|
247
|
+
were about to report a regression that did not exist. Read these two verdicts as *what this
|
|
248
|
+
process has asked so far*, never as a statement about the schema.
|
|
186
249
|
|
|
187
250
|
Each `manquant` entry has **exactly this shape** — pin your parser to it, not to what a schema
|
|
188
251
|
probe "should" return:
|
|
@@ -202,7 +265,6 @@ If you consume this card, test your parser against the JSON above, not against a
|
|
|
202
265
|
⚠️ **A card without a `schema` field is an alert, not a success**: it signals an instance older
|
|
203
266
|
than 0.1.58 — a version that cannot answer the question. (Rule contributed by the second host, for
|
|
204
267
|
exactly the monitoring case where "no data" would otherwise read as "all clear".)
|
|
205
|
-
| `indetermine` | the database did not answer; this measurement did not happen |
|
|
206
268
|
|
|
207
269
|
⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
|
|
208
270
|
on its own, even when the rest has not been checked.
|
|
@@ -465,6 +527,93 @@ not from a list of column names borrowed from someone else's — a coarse check
|
|
|
465
527
|
positives to excuse, it has a pattern to derive. The question each row must be able to answer is
|
|
466
528
|
*which document was this written for*; the key that answers it is yours to name.
|
|
467
529
|
|
|
530
|
+
**Your `bot` plugin owns the assistant's behaviour — all of it.** The player ships the assistant's
|
|
531
|
+
markup and wires **none** of its sixty-four controls: no browser bundle, no inline script. That has
|
|
532
|
+
been true since the first commit, and until 26/08 it was undocumented while `docs/CONFIGURATION.md`
|
|
533
|
+
claimed the opposite. Two consequences you must act on:
|
|
534
|
+
|
|
535
|
+
- **Declare `wiresVoice: true`** on the object you pass as `ctx.plugins.bot` if you wire the voice
|
|
536
|
+
controls. Without it — or with a merely truthy value rather than exactly `true` — the three voice
|
|
537
|
+
buttons and the audio-consent step are not rendered at all. `ELEVENLABS_API_KEY` alone no longer
|
|
538
|
+
shows them: the key proves the *server* can synthesise, never that a click leads anywhere, and a
|
|
539
|
+
button that leads to silence is a broken promise made in your name.
|
|
540
|
+
- **`bot-tts` now requires a `sessionId`**, bound to the requested `slug`, and the text must match
|
|
541
|
+
something the assistant said in that session. The player reads `listMessages(sessionId)` and
|
|
542
|
+
treats a message as the assistant's when its `role` is `bot`, `assistant` or `ai`, taking the text
|
|
543
|
+
from `text` or `content`. **Anything it cannot read counts as "not said"** — an unrecognised shape
|
|
544
|
+
yields an empty set and every request is refused. On the one route that spends money, *"I could
|
|
545
|
+
not verify"* must read as **no**, never as *go ahead*.
|
|
546
|
+
|
|
547
|
+
⚠️ **The player does not delegate that check to your plugin**, for the reason already stated above
|
|
548
|
+
about session binding: a security property of the player cannot depend on code the player does not
|
|
549
|
+
contain. It reads the messages and decides itself.
|
|
550
|
+
|
|
551
|
+
The message text is read from `text`, `content` or `body`, first non-empty wins. `body` is there
|
|
552
|
+
because a host said so **before** hitting it: its messages carry `body` and nothing else, its `role`
|
|
553
|
+
was a correct `bot`, and the reader would have returned an empty string for every message — an empty
|
|
554
|
+
set, so every request refused, on a perfectly correct integration. If your field is none of those
|
|
555
|
+
three, tell us and we widen the list. The field name carries no security; the **role** filter does.
|
|
556
|
+
|
|
557
|
+
**If you write to the `tts-cache` bucket yourself, write the trace too.** Retention removes an object
|
|
558
|
+
only when its fingerprint has a row in `doc_tts_objects`, and only the player's own route writes that
|
|
559
|
+
row. Anything your code puts in that bucket is therefore invisible to the sweep — **permanently**,
|
|
560
|
+
not just for the objects already there.
|
|
561
|
+
|
|
562
|
+
This is not hypothetical: an integrating host reported 908 objects it had written itself, under
|
|
563
|
+
**exactly** the player's naming — same digest, same two files, same bucket root. Its own comment says
|
|
564
|
+
the parity was deliberate, so that one clip serves both surfaces. Nothing about the name distinguishes
|
|
565
|
+
its objects from the player's; only the missing row does.
|
|
566
|
+
|
|
567
|
+
So `doc_tts_objects` is a **host write point**, not an internal table. What the sweep needs from
|
|
568
|
+
you is **one property, and only this one**:
|
|
569
|
+
|
|
570
|
+
> the `hash` you write in the row **is** the object's base name — the file is `<hash>.mp3`, its
|
|
571
|
+
> alignment is `<hash>.json`, and nothing else has to be true.
|
|
572
|
+
|
|
573
|
+
⚠️ **How you compute that digest is yours, and this page used to say otherwise.** It read *"write
|
|
574
|
+
it with the same fingerprint the player computes, and nothing else"*, which made a perfectly safe
|
|
575
|
+
host non-compliant on paper — and the obvious fix, realigning the formula, is the one thing that
|
|
576
|
+
would break: the objects already in the bucket carry the **old** digest in their names, so a row
|
|
577
|
+
written with a new one points at nothing, and the real name loses its only row. Reported on 27/08
|
|
578
|
+
by a host whose third writer uses `preview-fr-v2` where the player uses `v2`. Their five write
|
|
579
|
+
sites are correct as they stand.
|
|
580
|
+
|
|
581
|
+
The sweep never recomputes anything: it reads `hash` from the row and removes `hash + ".mp3"` and
|
|
582
|
+
`hash + ".json"` (`server/retention.js`). A bench holds that property rather than a comment —
|
|
583
|
+
`retentionCacheDeVoix.test.js` builds its rows with `hash: "aaa"`, which is the sha256 of nothing,
|
|
584
|
+
and requires `tts-cache/aaa.mp3` to be the file removed.
|
|
585
|
+
|
|
586
|
+
**The player's own formula matters for one thing only, and it is not retention** — sharing. Its
|
|
587
|
+
route recomputes this digest to find a clip it already paid for, so match it *if* you want one clip
|
|
588
|
+
to serve both surfaces (as one host deliberately does, for 908 objects). If you don't, the player
|
|
589
|
+
simply synthesises its own, and nothing else changes:
|
|
590
|
+
|
|
591
|
+
```
|
|
592
|
+
hash = sha256(voiceId + "|" + modelId + "|v2|" + spokenText) -- hex, lowercase
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
| requirement | mandatory? | if you don't |
|
|
596
|
+
|---|---|---|
|
|
597
|
+
| the row's `hash` is the object's base name | **yes, always** | the object is invisible to the sweep, permanently |
|
|
598
|
+
| the digest matches the player's formula | no — only to share a clip | the player synthesises its own, and pays for it |
|
|
599
|
+
|
|
600
|
+
```sql
|
|
601
|
+
insert into public.doc_tts_objects (hash) values ($1)
|
|
602
|
+
on conflict (hash) do nothing;
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
⚠️ **Never write the text**, in any column. The table holds a fingerprint and a date on purpose: the
|
|
606
|
+
bucket may already hold personal data, and writing the text would recreate it in the database — this
|
|
607
|
+
time queryable. `hash` is the primary key, so the insert is idempotent; a clip regenerated under a new
|
|
608
|
+
voice yields a new fingerprint and a new row, which is correct.
|
|
609
|
+
|
|
610
|
+
RLS is on with **no policy**, so nothing reaches it except a role that bypasses RLS — the
|
|
611
|
+
`service_role` key the `db` capability already uses. No grant, no schema change, no new migration:
|
|
612
|
+
apply `0021` and write.
|
|
613
|
+
|
|
614
|
+
Objects written before you start writing the trace stay untraceable for good. The sweep counts rows,
|
|
615
|
+
so it can say *"no trace outside the window survives"* — never *"the bucket is clean"*.
|
|
616
|
+
|
|
468
617
|
**A database error carries its status as a number, not inside its message.** Set `statusCode` (or
|
|
469
618
|
`status`) on whatever `db.request` throws. Both contexts shipped here already do; a host that
|
|
470
619
|
implements the seam itself may not, and the player then has to guess from the text.
|
package/docs/RETENTION.md
CHANGED
|
@@ -111,8 +111,45 @@ Agent-guided walkthrough: **purged 13 months** after `last_at`.
|
|
|
111
111
|
|---|---|---|
|
|
112
112
|
| `player_rate_limits.key` | may contain an **IP in the clear** (`hshare:<ip>`) or an email | row purged as soon as `expires_at` has passed (opportunistically, on every pass) |
|
|
113
113
|
|
|
114
|
+
## Voice cache (`doc_tts_objects` + the `tts-cache` bucket)
|
|
115
|
+
|
|
116
|
+
Every synthesis writes two objects to the **public** `tts-cache` bucket — `<fingerprint>.mp3` and
|
|
117
|
+
`<fingerprint>.json` (per-character alignment). The fingerprint is a digest of voice + model +
|
|
118
|
+
**spoken** text. They are **purged 13 months** after `created_at`: the two objects first, then the
|
|
119
|
+
row — never the other way round, because erasing the trace first would leave the objects
|
|
120
|
+
permanently unreachable.
|
|
121
|
+
|
|
122
|
+
| column | contents | fate |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `doc_tts_objects.hash` | the fingerprint — **never the text** | purged with the row, after both bucket objects |
|
|
125
|
+
| `doc_tts_objects.created_at` | when the object was written | the window is measured on it |
|
|
126
|
+
|
|
127
|
+
⚠️ **Why a table exists at all.** The objects are named by a digest that ties back to no row, and
|
|
128
|
+
the host `storage` capability exposes `put` and `remove` — never `list`. Before this table the
|
|
129
|
+
bucket could not be swept at all: there was nothing to walk. This is not a policy that was missing,
|
|
130
|
+
it is the trace. The row records a fingerprint and a date and nothing else: writing the text here
|
|
131
|
+
would recreate, inside the database, whatever personal data the bucket may already hold — and make
|
|
132
|
+
it queryable, which is strictly worse than not having it.
|
|
133
|
+
|
|
134
|
+
⚠️ **A visitor chooses what goes in.** `bot-tts` accepts the caller's text, so a unique text leaves
|
|
135
|
+
an MP3 and a JSON in a public bucket. The grouping and ceilings added in 0.1.140 bound the cost per
|
|
136
|
+
hour; only this window bounds the **duration**.
|
|
137
|
+
|
|
114
138
|
## Limits stated rather than left unsaid
|
|
115
139
|
|
|
140
|
+
- ⚠️ **`fichiersErreur` can be high without any removal having failed.** Each fingerprint has two
|
|
141
|
+
objects, and the alignment `.json` is not always there — the provider does not always return one.
|
|
142
|
+
Measured on an integrating host's bucket on 27/08: **552 `.mp3` for 356 `.json`**, so 196 audio
|
|
143
|
+
files legitimately have no companion to remove. The count is deliberately not masked, but read it
|
|
144
|
+
with that in mind: a first sweep reporting two hundred "errors" may have failed at nothing.
|
|
145
|
+
- **If you write to the `tts-cache` bucket yourself, write the trace too**, or your objects are
|
|
146
|
+
invisible to the sweep permanently — see *Voice* in `docs/HOST-CONTRACT.md` for the exact
|
|
147
|
+
fingerprint and the insert. The same host had written **908 objects under the player's exact
|
|
148
|
+
naming**; nothing but the missing row distinguished them.
|
|
149
|
+
- **Voice-cache objects written before migration 0021 have no trace, and never will.** The sweep
|
|
150
|
+
can only reach what a row points at, and no row was ever written for them. They stay in the
|
|
151
|
+
bucket until an operator removes them by hand. The census counts rows, so it cannot see them
|
|
152
|
+
either — it can say "no trace past the window survives", never "the bucket is clean".
|
|
116
153
|
- **Orphaned attachments**: purging the rows erases the bucket file only if the host context
|
|
117
154
|
provides `storage.remove` (an optional capability). Without it, the URL becomes unreachable from
|
|
118
155
|
the product but the object survives in the bucket — said here rather than simulated.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.142",
|
|
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",
|
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
"test:e2e": "node -e \"require('fs').existsSync('vitest.e2e.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.e2e.config.mjs",
|
|
75
75
|
"test:base": "node -e \"require('fs').existsSync('vitest.base.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.base.config.mjs",
|
|
76
76
|
"test:charge": "node -e \"require('fs').existsSync('vitest.charge.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.charge.config.mjs",
|
|
77
|
-
"test:campagne": "node -e \"require('fs').existsSync('vitest.campagne.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.campagne.config.mjs"
|
|
77
|
+
"test:campagne": "node -e \"require('fs').existsSync('vitest.campagne.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.campagne.config.mjs",
|
|
78
|
+
"test:endurance": "node -e \"require('fs').existsSync('vitest.endurance.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.endurance.config.mjs",
|
|
79
|
+
"test:stats": "node -e \"require('fs').existsSync('vitest.stats.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.stats.config.mjs"
|
|
78
80
|
},
|
|
79
81
|
"engines": {
|
|
80
82
|
"node": ">=22.13.0"
|
package/server/cache.js
CHANGED
|
@@ -176,6 +176,17 @@ function creerCache(options) {
|
|
|
176
176
|
return promesse;
|
|
177
177
|
},
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Oublie ce qui est RETENU, jamais ce qui est en vol — la même règle que l'éviction : retirer
|
|
181
|
+
* une demande en cours ne libère rien et casserait le regroupement pour ses appelants.
|
|
182
|
+
*
|
|
183
|
+
* ⚠️ EXISTE PARCE QU'UN CACHE DE MODULE SURVIT À UN BANC. Deux bancs qui demandent la même clé
|
|
184
|
+
* dans le même processus : le second est servi par la mémoire du premier et n'observe donc PAS
|
|
185
|
+
* ce qu'il croit observer — il a compté zéro appel réseau et conclu que la route n'appelait
|
|
186
|
+
* pas. Un banc qui ne remet pas cet état à zéro mesure le banc d'avant.
|
|
187
|
+
*/
|
|
188
|
+
vider() { for (const [k, e] of [...entrees]) if (!e.enVol) oublier(k); },
|
|
189
|
+
|
|
179
190
|
/** Pour les tests et l'exploitation : ce que la table contient réellement. */
|
|
180
191
|
taille: () => entrees.size,
|
|
181
192
|
/** Poids cumulé des résultats retenus, en OCTETS UTF-8. */
|
package/server/erreurs-base.js
CHANGED
|
@@ -57,4 +57,37 @@ function estConflit(erreur) {
|
|
|
57
57
|
return /→\s*409\b/.test(String((erreur && erreur.message) || ""));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// ⚠️ « CETTE FONCTION N'EXISTE PAS ICI » VIT AVEC « CE CONFLIT EST UN CONFLIT », ET PAS AILLEURS.
|
|
61
|
+
// Elle habitait `presentations.js`, où elle était née ; deux autres modules en ont désormais besoin
|
|
62
|
+
// pour replier sur un chemin en mémoire quand une migration n'a pas été appliquée. La recopier
|
|
63
|
+
// aurait donné deux définitions d'un même fait — exactement ce que ce fichier existe pour empêcher.
|
|
64
|
+
// `presentations.js` la ré-exporte : sa surface publique ne bouge pas.
|
|
65
|
+
/**
|
|
66
|
+
* Cette erreur dit-elle « CETTE SIGNATURE N'EXISTE PAS », et rien d'autre ?
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ C'EST LA QUESTION QUI MANQUAIT, ET SON ABSENCE RETIRAIT UNE PROTECTION. Le repli vers l'ancien
|
|
69
|
+
* contrat se déclenchait sur N'IMPORTE QUELLE exception : un `ECONNRESET`, un 500, un délai dépassé
|
|
70
|
+
* valaient « migration 0018 absente », et le processus restait dégradé — sans contrôle anti-usurpation
|
|
71
|
+
* — jusqu'à son redémarrage. Une panne réseau d'une seconde désarmait une garde de sécurité sur une
|
|
72
|
+
* base pourtant entièrement migrée.
|
|
73
|
+
*
|
|
74
|
+
* C'est la règle du jour appliquée au code de production : **un mécanisme qui ne peut pas mesurer doit
|
|
75
|
+
* refuser de conclure, pas conclure par défaut.** Ici, ne pas savoir distinguer PGRST202 d'un timeout
|
|
76
|
+
* ne rendait pas le repli prudent — il le rendait automatique.
|
|
77
|
+
*
|
|
78
|
+
* PostgREST rend `PGRST202` quand aucune fonction ne correspond au jeu d'arguments nommés. On accepte
|
|
79
|
+
* les DEUX formes que nos contextes produisent (code analysé, ou message contenant le code / la phrase
|
|
80
|
+
* de PostgREST) — et RIEN d'autre : un statut 404 seul ne suffit pas, il peut venir d'ailleurs.
|
|
81
|
+
*/
|
|
82
|
+
function signatureAbsente(erreur) {
|
|
83
|
+
if (!erreur) return false;
|
|
84
|
+
const code = erreur.details && (erreur.details.code || (erreur.details.error && erreur.details.error.code));
|
|
85
|
+
if (code === "PGRST202") return true;
|
|
86
|
+
// ⚠️ PAS DE `erreur &&` ICI : la garde de la première ligne l'a déjà tranché. Le garder ne
|
|
87
|
+
// protégeait de rien et APPRENAIT AU LECTEUR QUE `erreur` PEUT ÊTRE NULLE À CET ENDROIT — ce qui
|
|
88
|
+
// est faux. Un test qui ne peut pas échouer ne coûte pas un cycle, il coûte une lecture.
|
|
89
|
+
const texte = String(erreur.message || "");
|
|
90
|
+
return texte.includes("PGRST202") || /Could not find the function/i.test(texte);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { estConflit, signatureAbsente };
|
package/server/gabarit-agent.js
CHANGED
|
@@ -10,6 +10,31 @@ const { esc } = require("./texte");
|
|
|
10
10
|
let PLAYER = null;
|
|
11
11
|
const init = (ctx) => { PLAYER = ctx; };
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* La voix se propose-t-elle au visiteur ? DEUX conditions, et la seconde est arrivée le 26/08.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ LA CLÉ SEULE NE PROUVE RIEN, ET C'EST POURTANT CE QU'ELLE PRÉTENDAIT. Elle dit que le SERVEUR
|
|
17
|
+
* sait synthétiser ; elle ne dit rien de ce qui arrive quand on clique. Or ce paquet ne câble AUCUN
|
|
18
|
+
* des soixante-quatre contrôles de cet assistant — il en livre le balisage, l'hôte livre le
|
|
19
|
+
* comportement. Les trois boutons de voix et l'étape de consentement audio étaient les SEULS dont
|
|
20
|
+
* l'apparition dépendait d'un secret serveur : les soixante autres s'affichent toujours, donc un
|
|
21
|
+
* hôte qui embarque cet assistant sait qu'il doit les brancher. Ceux-là, non — une clé posée les
|
|
22
|
+
* faisait apparaître, et un visiteur qui cliquait obtenait le silence.
|
|
23
|
+
*
|
|
24
|
+
* Signalé le 26/08 par un hôte intégrateur qui est allé chercher qui appelait `bot-tts` et n'a
|
|
25
|
+
* trouvé personne. Ce fichier portait DÉJÀ la règle, dans son banc : « une porte "écouter la
|
|
26
|
+
* présentation" qui mène au silence est une promesse cassée ». Elle était vérifiée SANS clé,
|
|
27
|
+
* jamais AVEC — le seul cas où la porte pouvait exister.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ `=== true` PLUTÔT QUE VÉRIDIQUE. Une fonction, une chaîne ou un objet posé là par accident
|
|
30
|
+
* ouvrirait la porte : on exige une DÉCLARATION, pas une présence.
|
|
31
|
+
*/
|
|
32
|
+
function voixProposable() {
|
|
33
|
+
if (!process.env.ELEVENLABS_API_KEY) return false;
|
|
34
|
+
const bot = PLAYER && PLAYER.plugins && PLAYER.plugins.bot;
|
|
35
|
+
return !!(bot && bot.wiresVoice === true);
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
const BOT_CSS = `
|
|
14
39
|
/* Doc + chat côte à côte (ces styles ne vivent sinon que dans LIVE_CSS, réservé au mode présentation). */
|
|
15
40
|
.lrow{flex:1;display:flex;min-height:0;position:relative}
|
|
@@ -539,15 +564,15 @@ function botMarkup(share, pitch) {
|
|
|
539
564
|
const acc = esc(share.bot_accent || "#15130f");
|
|
540
565
|
const avatar = share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : (share.bot_name ? esc(String(share.bot_name).trim().charAt(0).toUpperCase()) : "◆");
|
|
541
566
|
// Voix (ElevenLabs) : les boutons 🔊 ne sont proposés que si une clé est configurée côté serveur.
|
|
542
|
-
const voiceBtn =
|
|
543
|
-
const pVoiceBtn =
|
|
567
|
+
const voiceBtn = voixProposable() ? `<button class=botc-voice id=botcVoice title="Écouter la présentation" aria-pressed=false>${ICONS.mute}</button>` : "";
|
|
568
|
+
const pVoiceBtn = voixProposable() ? `<button id=botpVoice title="Écouter">${ICONS.mute}</button>` : "";
|
|
544
569
|
// Étape 2 du sélecteur de départ (uniquement si la voix est disponible) : le CONSENTEMENT audio se donne
|
|
545
570
|
// ICI, clairement, avant de lancer la présentation — audio interactif (voix + chat) ou par écrit.
|
|
546
571
|
const hasVClips = !!share.bot_vclips;
|
|
547
572
|
const videoDoor = hasVClips ? `<button class=botw-door id=doorVideo><i>${ICONS.play}</i><span>En vidéo avec ${name}<small>${name} vous présente face caméra — le format le plus vivant</small></span><em class=botw-tag>Populaire</em></button>` : "";
|
|
548
573
|
const vNote = !hasVClips && share.video_layout ? `<p class=botw-note>🎬 La présentation vidéo arrive bientôt sur ce document.</p>` : "";
|
|
549
|
-
const s2 =
|
|
550
|
-
return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${
|
|
574
|
+
const s2 = voixProposable() ? `<div class="botw-card botw-s2"><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><p class=botw-q>Parfait ! Comment préférez-vous suivre la présentation ?</p>${videoDoor}<button class=botw-door id=doorVoice><i>${ICONS.sound}</i><span>${hasVClips ? "En audio" : `Avec la voix de ${name}`}<small>Audio interactif — écoutez la présentation et posez vos questions dans le chat à tout moment</small></span>${hasVClips ? "" : `<em class=botw-tag>Populaire</em>`}</button><button class=botw-door id=doorSilent><i>${ICONS.chat}</i><span>Par écrit, dans le chat<small>${name} écrit page après page, en silence — à votre rythme</small></span></button>${vNote}<button class=botw-back id=botwBack>← Revenir aux options</button></div>` : "";
|
|
575
|
+
return `<div class="botc min" id=botc style="--bacc:${acc}"><div class=botc-grab id=botcGrab><i></i></div><div class=botc-h><span class=botc-av>${avatar}</span><div><b>${name}</b><span class=botc-sub>${sub}</span></div>${voiceBtn}<button class=botc-gearbtn id=botcGearBtn title="Réglages d'affichage">${ICONS.gear}</button><button class=botc-min id=botcMin title=Réduire>${ICONS.min}</button></div><button class=botc-back id=botcBack>${ICONS.prev}<span>Revenir à la présentation</span></button><div class=botc-msgs id=botcMsgs><div class=botc-choices id=botcChoices></div></div><button class=botc-resume id=botcResume>Reprendre la présentation</button><div class=botc-in><input id=botcText placeholder="Écrivez votre message…" autocomplete=off maxlength=1000><button id=botcSend title=Envoyer>${ICONS.send}</button></div></div><button class=botc-fab id=botcFab style="--bacc:${acc}" title="Assistant & réglages">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}<span class=botc-badge id=botcBadge></span><span class=botc-gear>${ICONS.gear}</span><span class=fab-gear>${ICONS.gear}</span></button><button class=botc-fab2 id=botcFab2 title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${ICONS.chat}<span class=botc-badge id=botcBadge2></span></button>${voixProposable() ? `<button class="botc-fab2 botc-fab3" id=botcVoice2 title="Couper la voix"></button>` : ""}<button class="botc-fab2 botc-fab4" id=botcPlay2 title="Relancer une visite">${ICONS.play}</button><button class=botc-peek id=botcPeek></button><div class=fabmenu id=fabMenu style="--bacc:${acc}"><div class=fm-l0 id=fmL0><button class=fm-row data-p=pDisp><span class=fm-rl>Affichage</span><em id=fmVDisp></em>${ICONS.next}</button><button class=fm-row data-p=pLang><span class=fm-rl>Langue</span><em id=fmVLang></em>${ICONS.next}</button><button class=fm-row data-p=pLook><span class=fm-rl>Apparence</span><em id=fmVLook></em>${ICONS.next}</button></div><div class=fm-p id=pDisp><button class=fm-back>${ICONS.prev}<span class=fm-rl>Affichage</span></button><div class=fm-seg id=fmDisp><button data-v=panel>Panneau</button><button data-v=bubble>Bulle</button><button data-v=cap>Barre</button><button data-v=audio>Audio seul</button></div></div><div class=fm-p id=pLang><button class=fm-back>${ICONS.prev}<span class=fm-rl>Langue</span></button><div class=fm-seg id=fmLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div></div><div class=fm-p id=pLook><button class=fm-back>${ICONS.prev}<span class=fm-rl>Apparence</span></button><div class=fm-sec>Thème</div><div class=fm-seg id=fmTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=fmStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div><div class=botw id=botw style="--bacc:${acc}"><button class=botw-x id=botwX aria-label=Fermer>${ICONS.close}</button><div class=botw-card><div class=botw-head><span class=botw-av>${avatar}</span><div><b>${name}</b><span>${sub}</span></div></div><div class=botw-lang id=botwLang><button data-v=fr>FR</button><button data-v=en>EN</button><button data-v=es>ES</button></div>${pitch ? `<p class=botw-pitch>${esc(pitch)}</p>` : ""}<p class=botw-q>Comment souhaitez-vous découvrir ce document ?</p><button class=botw-door id=doorPresent><i>${ICONS.play}</i><span>Je me laisse guider<small>${name} vous présente le document, à votre rythme</small></span><em class=botw-tag>Recommandé</em></button><button class=botw-door id=doorRead><i>${ICONS.book}</i><span>Je le parcours seul<small>Lecture libre — l'assistant reste disponible</small></span></button><button class=botw-door id=doorChat><i>${ICONS.chat}</i><span>J'ai des questions<small>Échangez directement avec ${name}</small></span></button></div>${s2}</div><div class=botp id=botp style="--bacc:${acc}"><div class=botp-prog id=botpProg><i id=botpFill></i></div><div class=botp-cap id=botpCap></div><div class=botp-chips id=botpChips></div><div class=botp-ctl><button class=pp id=botpPP aria-label="Lecture / pause">${ICONS.pause}${ICONS.play}</button>${pVoiceBtn}<button id=botpFs title="Plein écran">${ICONS.fs}</button><button id=botpChat title="Parler à ${esc(String(share.bot_name || "l'assistant"))}">${share.bot_avatar ? `<img src="${esc(share.bot_avatar)}" alt="">` : ICONS.chat}</button><button id=botpMore title=Options>${ICONS.more}</button></div><button class=botp-big id=botpBig aria-label=Reprendre>${ICONS.play}</button><div class=rot-hint id=rotHint><i class=rh-ph><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18.2h2"/></svg></i><div class=rh-t><b>Plein écran</b><span>Tournez votre téléphone</span></div></div><div class=botp-menu id=botpMenu><button id=bmChat>${ICONS.chat}<span>Poser une question</span></button><button id=bmCall>${ICONS.cal}<span>Être rappelé / prendre RDV</span></button><button id=bmDl>${ICONS.dl}<span>Télécharger le document</span></button><button id=bmRestart>${ICONS.restart}<span>Recommencer la présentation</span></button><button id=bmRead>${ICONS.book}<span>Consulter tranquillement</span></button><button id=bmSet class=bm-set>${ICONS.gear}<span>Réglages</span></button></div><div class="botp-menu botp-set" id=botpSet><button class=bs-back id=bsBack>${ICONS.prev}<span>Réglages</span></button><div class=fm-sec>Vitesse de lecture</div><div class=fm-seg id=msSpd><button data-v=1>1×</button><button data-v=1.5>1,5×</button><button data-v=2>2×</button></div><div class=fm-sec>Thème</div><div class=fm-seg id=msTheme><button data-v=dark>Sombre</button><button data-v=light>Clair</button></div><div class=fm-sec>Style du texte</div><div class=fm-seg id=msStyle><button data-v=classic>Classique</button><button data-v=focus>Focus</button><button data-v=fill>Encre</button><button data-v=underline>Souligné</button></div></div></div>`;
|
|
551
576
|
}
|
|
552
577
|
|
|
553
578
|
module.exports = { init, BOT_CSS, ICO, ICONS, botMarkup };
|
package/server/handler.js
CHANGED
|
@@ -11,6 +11,7 @@ const { pipeline } = require("node:stream/promises");
|
|
|
11
11
|
const { getShareBySlug } = require("./shares");
|
|
12
12
|
const { PRESENT_QUOTA_PER_HOUR, PRESENT_CACHE_MS, estSlug } = require("./shared.generated.js");
|
|
13
13
|
const { creerCache, CODE_SATURATION } = require("./cache.js");
|
|
14
|
+
const mesures = require("./mesures.js");
|
|
14
15
|
|
|
15
16
|
// ⚠️ UN SEUL CACHE POUR TOUT LE PROCESSUS, ET C'EST LE POINT. Le créer par requête reviendrait à
|
|
16
17
|
// n'en avoir aucun : chaque appelant repartirait d'une table vide, et l'effondrement qu'on
|
|
@@ -27,6 +28,17 @@ const { getPresentation, listMessages } = require("./presentations");
|
|
|
27
28
|
// Vercel (api/doc.js) est le seul à connaître le studio.
|
|
28
29
|
let PLAYER = null;
|
|
29
30
|
function init(ctx) {
|
|
31
|
+
// ⚠️ LA LATENCE DE LA BASE SE MESURE AU SEAM, PAS À SOIXANTE-SEPT SITES D'APPEL. La capacité est
|
|
32
|
+
// fournie par l'HÔTE : se souvenir de chronométrer à chaque appel demanderait de ne jamais
|
|
33
|
+
// oublier, et le premier oubli passerait inaperçu. Enveloppée ici, la mesure couvre AUSSI ce que
|
|
34
|
+
// personne n'a encore écrit. Le décorateur rend la même forme, les mêmes valeurs et les mêmes
|
|
35
|
+
// rejets : un décorateur qui change le contrat mesurerait autre chose que la production.
|
|
36
|
+
//
|
|
37
|
+
// ⚠️ `Object.create`, PAS UNE COPIE — le contexte reste VIVANT. Un `{ ...ctx }` figerait tout ce
|
|
38
|
+
// que l'hôte pourrait poser ou remplacer APRÈS `init`, et le player continuerait d'utiliser la
|
|
39
|
+
// photo. C'est exactement ce qui a rougi la forge : un banc pose sa sonde sur `db.request` après
|
|
40
|
+
// `init`, et un hôte a le même droit. On n'ajoute qu'une chose, on n'en fige aucune.
|
|
41
|
+
if (ctx && ctx.db) { const vu = Object.create(ctx); vu.db = mesures.observerBase(ctx.db); ctx = vu; }
|
|
30
42
|
PLAYER = ctx;
|
|
31
43
|
// Le domaine reçoit le même contexte : une seule construction pour tout le player.
|
|
32
44
|
require("./shares").init(ctx);
|
|
@@ -512,7 +524,39 @@ function parametres(req) {
|
|
|
512
524
|
}
|
|
513
525
|
}
|
|
514
526
|
|
|
527
|
+
/**
|
|
528
|
+
* ⚠️ UNE FAMILLE PAR NATURE DE TRAVAIL, PAS PAR ACTION. Une clé par action rendrait le relevé aussi
|
|
529
|
+
* long que la liste des routes et aussi instable qu'elle — et il faudrait la tenir à jour à la
|
|
530
|
+
* main, ce que ce dépôt a déjà payé trois fois. Six familles suffisent à répondre à « qu'est-ce
|
|
531
|
+
* qui est lent ici ? » ; le détail se cherche ensuite, avec la question déjà posée.
|
|
532
|
+
*/
|
|
533
|
+
function familleDe(req, q) {
|
|
534
|
+
if (req.method === "POST") return "action";
|
|
535
|
+
if (q.contract) return "carte";
|
|
536
|
+
if (q.asset || q.stream || q.file) return "fichier";
|
|
537
|
+
if (q.present) return "presentation";
|
|
538
|
+
if (q.slug || q.preview) return "document";
|
|
539
|
+
return "autre";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* ⚠️ LE CHRONOMÈTRE ENVELOPPE, IL NE S'INSÈRE PAS. Le gestionnaire sort par des dizaines de
|
|
544
|
+
* `return` ; poser une mesure à chacun serait une liste à tenir, donc une liste qui divergera. Le
|
|
545
|
+
* statut se lit sur `res` APRÈS coup — c'est la porte de réponse qui l'a posé, et elle est unique
|
|
546
|
+
* (`server/reponses.js`). Le `finally` couvre aussi le chemin d'exception : une requête qui casse
|
|
547
|
+
* est précisément celle qu'on veut voir comptée.
|
|
548
|
+
*/
|
|
515
549
|
async function handler(req, res) {
|
|
550
|
+
let fin = () => {};
|
|
551
|
+
try { fin = mesures.chrono(familleDe(req, parametres(req))); } catch { /* jamais bloquant */ }
|
|
552
|
+
try {
|
|
553
|
+
return await handlerMesure(req, res);
|
|
554
|
+
} finally {
|
|
555
|
+
try { fin(res && res.statusCode); } catch { /* jamais bloquant */ }
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function handlerMesure(req, res) {
|
|
516
560
|
try {
|
|
517
561
|
const q = parametres(req);
|
|
518
562
|
const slug = String(q.slug || "").trim();
|
|
@@ -718,6 +762,24 @@ async function handler(req, res) {
|
|
|
718
762
|
derniereIlYaS: dernier == null ? null : Math.max(0, Math.round((Date.now() - dernier) / 1000)),
|
|
719
763
|
};
|
|
720
764
|
})(),
|
|
765
|
+
// ⚠️ CE QUE CETTE INSTANCE A VÉCU — parce que `lectureSaturee` ne répondait qu'à UNE
|
|
766
|
+
// question. « La route est-elle lente ? », « lesquelles ? », « la base ou nous ? »,
|
|
767
|
+
// « combien de 5xx ? », « la boucle décroche-t-elle ? » n'avaient aucune réponse
|
|
768
|
+
// observable, et les deux hôtes intégrateurs ont confirmé ne pas pouvoir la produire
|
|
769
|
+
// depuis chez eux. Décider d'optimiser sans ça, c'est deviner (audit CODEX 5.6, §2).
|
|
770
|
+
//
|
|
771
|
+
// ⚠️ DES BORNES, PAS DES VALEURS : `p95sousMs: 250` se lit « 95 % des appels sous
|
|
772
|
+
// 250 ms ». L'échelle des seaux est publiée AVEC les chiffres — sans elle, un lecteur ne
|
|
773
|
+
// peut pas juger de la précision de ce qu'il lit. Voir `server/mesures.js`.
|
|
774
|
+
//
|
|
775
|
+
// ⚠️ AUCUN SLUG, AUCUNE ADRESSE, AUCUN TEXTE : des compteurs et des durées. Rien ici ne
|
|
776
|
+
// désigne un visiteur, un document ou une présentation — c'est ce qui permet de le
|
|
777
|
+
// publier sur une carte qu'un hôte lit sans cérémonie.
|
|
778
|
+
//
|
|
779
|
+
// ⚠️ PROCESSUS-LOCAL, comme `lectureSaturee` et les champs de présence : une instance qui
|
|
780
|
+
// répond n'est pas toutes les instances, et tout repart à zéro au déploiement. Agréger
|
|
781
|
+
// est le travail de l'hôte ; `fenetreS` est là pour qu'il sache sur quoi il agrège.
|
|
782
|
+
mesures: mesures.relever(),
|
|
721
783
|
// ⚠️ LE BALAYAGE DE RÉTENTION EST-IL ARMÉ ? La capacité `retention` dit que l'instance PEUT
|
|
722
784
|
// purger ; ce booléen dit si le balayage automatique TOURNE (`config.retention.balayage`).
|
|
723
785
|
// Sans lui, une instance armée est indiscernable d'une instance éteinte — et une purge qui
|
|
@@ -1068,6 +1130,7 @@ async function handler(req, res) {
|
|
|
1068
1130
|
// tenir une seconde liste — c'est la seule façon qu'une empreinte périmée finisse par se voir.
|
|
1069
1131
|
// ⚠️ Exporté pour être ÉPROUVÉ, pas pour être appelé : le plafond du relais ne se vérifie qu en
|
|
1070
1132
|
// regardant si le corps a été lu, ce qu aucune route ne peut montrer de l extérieur.
|
|
1071
|
-
|
|
1133
|
+
// ⚠️ Exporté pour être ÉPROUVÉ : « le contexte reste vivant » ne se vérifie pas de l'extérieur.
|
|
1134
|
+
module.exports = { __contexte: () => PLAYER, handler, init, TIERS, POLITIQUE_PERMISSIONS, refuserEnTexte, repondreJson, __relayerFichier: relayerFichier, __jsonPourScript: jsonPourScript };
|
|
1072
1135
|
|
|
1073
1136
|
// redeploy: forcer le build production (Vercel a sauté la prod du merge #463 — wording re-partage).
|