perf(api): compression des réponses, et troncature de la carte enfin signalée

Le site public charge l'intégralité du corpus une seconde après l'ouverture de
la page (loadAllHeatPoints), pour la heatmap et la recherche. Rien ne
compressait dans la chaîne — ni Fastify, ni Traefik. Ce n'était donc pas un
risque théorique mais la facture de chaque visiteur, à chaque visite.

Mesuré sur 100 000 groupes avec des données réalistes, en-tête Accept-Encoding
d'un vrai navigateur : 21,5 Mo deviennent 1,9 Mo.

gzip est préféré à brotli, contre l'intuition : br sort 2,3 Mo là où gzip fait
1,9 Mo. La qualité brotli par défaut est réglée pour la vitesse, et sur ce JSON
très répétitif elle perd sur les deux tableaux à la fois — taille ET CPU. Seuil
à 1 Ko, en dessous duquel compresser coûte plus que ça ne rapporte.

/api/clusters plafonnait par ailleurs sa réponse à 2000 groupes ou 1000
clusters sans le dire, ni côté API ni côté client : dans une zone dense, des
groupes disparaissaient de la carte sans aucun signal. La réponse porte
désormais `truncated` et `limit`, et l'interface affiche un avertissement
invitant à zoomer. Clé de traduction ajoutée dans les 21 langues, la
convention du fichier étant une couverture complète.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas FRYDER 2026-08-22 14:35:12 +02:00
parent 2043705fc7
commit fcb20e2e11
8 changed files with 464 additions and 13 deletions

View file

@ -8,12 +8,13 @@
"start": "node src/server.js"
},
"dependencies": {
"fastify": "^4.28.1",
"@fastify/rate-limit": "^9.0.0",
"@fastify/helmet": "^11.1.1",
"@fastify/compress": "^7.0.3",
"@fastify/cookie": "^9.4.0",
"pg": "^8.12.0",
"@fastify/helmet": "^11.1.1",
"@fastify/rate-limit": "^9.0.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2"
"fastify": "^4.28.1",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
}
}

View file

@ -7,6 +7,7 @@
*/
import Fastify from "fastify";
import rateLimit from "@fastify/rate-limit";
import compress from "@fastify/compress";
import helmet from "@fastify/helmet";
import cookie from "@fastify/cookie";
import { timingSafeEqual } from "crypto";
@ -34,6 +35,12 @@ import { ValidationError, sanitizeSearchString, parseLimitOffset } from "./valid
* @param {number} [opts.adminRateLimitMax] requêtes/min sur /admin/import
* @param {number} [opts.authRateLimitMax] requêtes/min sur /admin/auth/login
*/
// Plafonds de /api/clusters. Nommés plutôt qu'écrits en dur dans le SQL : la
// réponse doit pouvoir dire qu'elle a été tronquée, ce qui suppose de comparer
// au même nombre des deux côtés.
const BANDS_AT_ZOOM_LIMIT = 2000;
const CLUSTERS_LIMIT = 1000;
export async function buildServer(opts = {}) {
const {
pool = null,
@ -60,6 +67,28 @@ export async function buildServer(opts = {}) {
trustProxy: true // derrière Traefik (+ nginx pour /admin/*) : lit X-Forwarded-For
});
// Compression.
//
// Le site public charge l'intégralité du corpus une seconde après l'ouverture
// de la page (loadAllHeatPoints), pour la heatmap et la recherche. Mesuré sur
// 100 000 groupes avec des données réalistes : 22 Mo non compressés contre
// 1,9 Mo en gzip. Rien ne compressait dans la chaîne — ni ici, ni Traefik.
// Ce n'était donc pas un risque théorique mais la facture de chaque visiteur,
// à chaque visite.
//
// Seuil à 1 Ko : en dessous, l'en-tête et le temps CPU coûtent plus que ce
// qu'ils économisent.
//
// gzip est préféré à brotli, contre l'intuition : mesuré sur la réponse
// complète de /api/bands, gzip sort 1,9 Mo contre 2,3 Mo pour brotli. La
// qualité brotli par défaut est réglée pour la vitesse, et sur ce JSON très
// répétitif elle perd sur les deux tableaux à la fois — taille ET CPU.
await fastify.register(compress, {
global: true,
threshold: 1024,
encodings: ["gzip", "br", "deflate"],
});
// Headers de sécurité
await fastify.register(helmet, {
contentSecurityPolicy: false,
@ -372,14 +401,20 @@ export async function buildServer(opts = {}) {
JOIN bands b ON b.ma_id = bl.ma_id
WHERE ${whereSql}
ORDER BY bl.ma_id ASC, bl.step_order ASC
LIMIT 2000;
LIMIT ${BANDS_AT_ZOOM_LIMIT};
`;
const r = await p.query(sql, vals);
// La réponse était plafonnée sans le dire : dans une zone dense, des
// groupes disparaissaient de la carte sans aucun signal, ni côté API ni
// côté client. `truncated` permet enfin à l'interface d'inviter à
// zoomer au lieu de mentir par omission.
return {
ok: true,
type: "bands",
items: r.rows,
count: r.rows.length
count: r.rows.length,
limit: BANDS_AT_ZOOM_LIMIT,
truncated: r.rows.length >= BANDS_AT_ZOOM_LIMIT
};
}
@ -436,7 +471,7 @@ export async function buildServer(opts = {}) {
END as sample_bands
FROM clusters
ORDER BY band_count DESC
LIMIT 1000;
LIMIT ${CLUSTERS_LIMIT};
`;
vals.push(cellSize);
@ -446,6 +481,8 @@ export async function buildServer(opts = {}) {
type: "clusters",
items: r.rows,
total_clusters: r.rows.length,
limit: CLUSTERS_LIMIT,
truncated: r.rows.length >= CLUSTERS_LIMIT,
cell_size: cellSize,
zoom: zoomLevel
};

View file

@ -58,6 +58,25 @@ describe("/api/health et /", () => {
});
});
describe("/api/clusters — troncature", () => {
it("signale la troncature quand le plafond est atteint", async () => {
// La réponse était plafonnée sans le dire : dans une zone dense, des
// groupes disparaissaient de la carte sans aucun signal.
const pleine = Array.from({ length: 2000 }, (_, i) => ({ ma_id: i }));
const res = await build([{ match: "FROM band_locations", result: { rows: pleine, rowCount: 2000 } }])
.inject({ method: "GET", url: "/api/clusters?bbox=0,0,10,10&zoom=13" });
const body = JSON.parse(res.payload);
expect(body.truncated).toBe(true);
expect(body.limit).toBe(2000);
});
it("ne signale rien quand tout tient", async () => {
const res = await build([{ match: "FROM band_locations", result: { rows: [{ ma_id: 1 }], rowCount: 1 } }])
.inject({ method: "GET", url: "/api/clusters?bbox=0,0,10,10&zoom=13" });
expect(JSON.parse(res.payload).truncated).toBe(false);
});
});
describe("CORS", () => {
it("renvoie l'en-tête pour une origine autorisée", async () => {
const res = await build().inject({

View file

@ -407,6 +407,18 @@ async function loadViewportBands() {
}
$("count").textContent = String(j.count || currentViewportData.length || 0);
// Le serveur plafonne sa réponse (2000 groupes, 1000 clusters). Il le dit
// désormais ; sans cet indicateur, des groupes disparaissaient de la carte
// dans les zones denses sans que rien ne le signale.
const note = $("truncNote");
if (note) {
note.hidden = !j.truncated;
if (j.truncated) {
note.textContent = "⚠";
note.title = t("truncated_hint").replace("{n}", String(j.limit ?? ""));
}
}
}
} catch (e) {
console.error("Failed to load viewport bands:", e);

View file

@ -113,7 +113,7 @@
</div>
<div class="stats">
<span class="chip"><span class="dot"></span><b id="count">0</b> <span data-i18n="displayed">affichés</span></span>
<span class="chip"><span class="dot"></span><b id="count">0</b> <span data-i18n="displayed">affichés</span><span id="truncNote" class="trunc-note" hidden></span></span>
<span class="chip"><b id="total">0</b> <span data-i18n="total_bands">total</span></span>
<span class="chip"><b id="unique">0</b> <span data-i18n="localities">localités</span></span>
<span class="chip"><b id="geocoded">0</b> <span data-i18n="located">localisés</span></span>

View file

@ -57,6 +57,7 @@
fr: {
search_placeholder: "Rechercher (nom, genre, ville, pays)…",
truncated_hint: "Seuls les {n} premiers résultats de cette zone sont affichés — zoomez pour tout voir.",
displayed: "affichés", total_bands: "total", localities: "localités", located: "localisés",
view_section: "Vue", reset_view: "Reset vue", heatmap_label: "Heatmap",
filters_section: "Filtres", without_coords_btn: "Sans coords",
@ -95,6 +96,7 @@
en: {
search_placeholder: "Search (name, genre, city, country)…",
truncated_hint: "Only the first {n} results in this area are shown — zoom in to see them all.",
displayed: "displayed", total_bands: "total", localities: "localities", located: "located",
view_section: "View", reset_view: "Reset view", heatmap_label: "Heatmap",
filters_section: "Filters", without_coords_btn: "Without coords",
@ -132,6 +134,7 @@
de: {
search_placeholder: "Suchen (Name, Genre, Stadt, Land)…",
truncated_hint: "Es werden nur die ersten {n} Ergebnisse in diesem Bereich angezeigt — zoomen Sie hinein, um alle zu sehen.",
displayed: "angezeigt", total_bands: "gesamt", localities: "Orte", located: "geortet",
view_section: "Ansicht", reset_view: "Ansicht zurücksetzen", heatmap_label: "Heatmap",
filters_section: "Filter", without_coords_btn: "Ohne Koordinaten",
@ -169,6 +172,7 @@
es: {
search_placeholder: "Buscar (nombre, género, ciudad, país)…",
truncated_hint: "Solo se muestran los primeros {n} resultados de esta zona — amplía para verlos todos.",
displayed: "mostrados", total_bands: "total", localities: "localidades", located: "localizados",
view_section: "Vista", reset_view: "Restablecer vista", heatmap_label: "Mapa de calor",
filters_section: "Filtros", without_coords_btn: "Sin coordenadas",
@ -206,6 +210,7 @@
it: {
search_placeholder: "Cerca (nome, genere, città, paese)…",
truncated_hint: "Vengono mostrati solo i primi {n} risultati di quest'area — ingrandisci per vederli tutti.",
displayed: "visualizzati", total_bands: "totale", localities: "località", located: "geolocalizzati",
view_section: "Vista", reset_view: "Reimposta vista", heatmap_label: "Mappa di calore",
filters_section: "Filtri", without_coords_btn: "Senza coordinate",
@ -243,6 +248,7 @@
pl: {
search_placeholder: "Szukaj (nazwa, gatunek, miasto, kraj)…",
truncated_hint: "Wyświetlono tylko pierwsze {n} wyników w tym obszarze — przybliż, aby zobaczyć wszystkie.",
displayed: "wyświetlono", total_bands: "łącznie", localities: "miejscowości", located: "zlokalizowano",
view_section: "Widok", reset_view: "Resetuj widok", heatmap_label: "Mapa cieplna",
filters_section: "Filtry", without_coords_btn: "Bez współrzędnych",
@ -280,6 +286,7 @@
nl: {
search_placeholder: "Zoeken (naam, genre, stad, land)…",
truncated_hint: "Alleen de eerste {n} resultaten in dit gebied worden getoond — zoom in om ze allemaal te zien.",
displayed: "weergegeven", total_bands: "totaal", localities: "locaties", located: "gelokaliseerd",
view_section: "Weergave", reset_view: "Weergave herstellen", heatmap_label: "Warmtekaart",
filters_section: "Filters", without_coords_btn: "Zonder coördinaten",
@ -317,6 +324,7 @@
ro: {
search_placeholder: "Căutare (nume, gen, oraş, țară)…",
truncated_hint: "Sunt afișate doar primele {n} rezultate din această zonă — apropie pentru a le vedea pe toate.",
displayed: "afişate", total_bands: "total", localities: "localități", located: "localizate",
view_section: "Vedere", reset_view: "Resetare vedere", heatmap_label: "Hartă termică",
filters_section: "Filtre", without_coords_btn: "Fără coordonate",
@ -354,6 +362,7 @@
pt: {
search_placeholder: "Pesquisar (nome, género, cidade, país)…",
truncated_hint: "Apenas os primeiros {n} resultados desta área são mostrados — amplie para ver todos.",
displayed: "exibidos", total_bands: "total", localities: "localidades", located: "localizados",
view_section: "Vista", reset_view: "Repor vista", heatmap_label: "Mapa de calor",
filters_section: "Filtros", without_coords_btn: "Sem coordenadas",
@ -391,6 +400,7 @@
cs: {
search_placeholder: "Hledat (název, žánr, město, země)…",
truncated_hint: "Zobrazeno je pouze prvních {n} výsledků v této oblasti — přibližte pro zobrazení všech.",
displayed: "zobrazeno", total_bands: "celkem", localities: "míst", located: "lokalizováno",
view_section: "Zobrazení", reset_view: "Obnovit zobrazení", heatmap_label: "Tepelná mapa",
filters_section: "Filtry", without_coords_btn: "Bez souřadnic",
@ -428,6 +438,7 @@
sv: {
search_placeholder: "Sök (namn, genre, stad, land)…",
truncated_hint: "Endast de första {n} resultaten i det här området visas — zooma in för att se alla.",
displayed: "visade", total_bands: "totalt", localities: "orter", located: "lokaliserade",
view_section: "Vy", reset_view: "Återställ vy", heatmap_label: "Värmekarta",
filters_section: "Filter", without_coords_btn: "Utan koordinater",
@ -465,6 +476,7 @@
hu: {
search_placeholder: "Keresés (név, műfaj, város, ország)…",
truncated_hint: "Csak az első {n} találat látható ezen a területen — nagyíts rá az összes megtekintéséhez.",
displayed: "megjelenített", total_bands: "összesen", localities: "helységek", located: "lokalizált",
view_section: "Nézet", reset_view: "Nézet alaphelyzetbe", heatmap_label: "Hőtérkép",
filters_section: "Szűrők", without_coords_btn: "Koordináták nélkül",
@ -502,6 +514,7 @@
da: {
search_placeholder: "Søg (navn, genre, by, land)…",
truncated_hint: "Kun de første {n} resultater i dette område vises — zoom ind for at se dem alle.",
displayed: "vist", total_bands: "i alt", localities: "lokaliteter", located: "lokaliserede",
view_section: "Visning", reset_view: "Nulstil visning", heatmap_label: "Varmekort",
filters_section: "Filtre", without_coords_btn: "Uden koordinater",
@ -539,6 +552,7 @@
fi: {
search_placeholder: "Haku (nimi, genre, kaupunki, maa)…",
truncated_hint: "Vain tämän alueen {n} ensimmäistä tulosta näytetään — lähennä nähdäksesi kaikki.",
displayed: "näytetty", total_bands: "yhteensä", localities: "paikkakunnat", located: "paikannettu",
view_section: "Näkymä", reset_view: "Palauta näkymä", heatmap_label: "Lämpökartta",
filters_section: "Suodattimet", without_coords_btn: "Ilman koordinaatteja",
@ -576,6 +590,7 @@
sk: {
search_placeholder: "Hľadať (názov, žáner, mesto, krajina)…",
truncated_hint: "Zobrazuje sa len prvých {n} výsledkov v tejto oblasti — priblížte pre zobrazenie všetkých.",
displayed: "zobrazené", total_bands: "celkom", localities: "miest", located: "lokalizované",
view_section: "Zobrazenie", reset_view: "Obnoviť zobrazenie", heatmap_label: "Tepelná mapa",
filters_section: "Filtre", without_coords_btn: "Bez súradníc",
@ -613,6 +628,7 @@
hr: {
search_placeholder: "Pretraži (ime, žanr, grad, država)…",
truncated_hint: "Prikazano je samo prvih {n} rezultata u ovom području — približite za prikaz svih.",
displayed: "prikazano", total_bands: "ukupno", localities: "lokaliteta", located: "locirano",
view_section: "Prikaz", reset_view: "Resetiraj prikaz", heatmap_label: "Toplinska karta",
filters_section: "Filtri", without_coords_btn: "Bez koordinata",
@ -650,6 +666,7 @@
sl: {
search_placeholder: "Iskanje (ime, zvrst, mesto, država)…",
truncated_hint: "Prikazanih je le prvih {n} rezultatov na tem območju — približajte za ogled vseh.",
displayed: "prikazano", total_bands: "skupaj", localities: "krajev", located: "lokalizirano",
view_section: "Pogled", reset_view: "Ponastavi pogled", heatmap_label: "Toplotna karta",
filters_section: "Filtri", without_coords_btn: "Brez koordinat",
@ -687,6 +704,7 @@
lt: {
search_placeholder: "Ieškoti (pavadinimas, žanras, miestas, šalis)…",
truncated_hint: "Rodomi tik pirmieji {n} šios srities rezultatai — priartinkite, kad matytumėte visus.",
displayed: "rodoma", total_bands: "iš viso", localities: "vietovių", located: "lokalizuota",
view_section: "Vaizdas", reset_view: "Atstatyti vaizdą", heatmap_label: "Šilumos žemėlapis",
filters_section: "Filtrai", without_coords_btn: "Be koordinačių",
@ -724,6 +742,7 @@
lv: {
search_placeholder: "Meklēt (nosaukums, žanrs, pilsēta, valsts)…",
truncated_hint: "Tiek rādīti tikai pirmie {n} rezultāti šajā apgabalā — pietuviniet, lai redzētu visus.",
displayed: "rādīts", total_bands: "kopā", localities: "vietu", located: "lokalizēts",
view_section: "Skats", reset_view: "Atiestatīt skatu", heatmap_label: "Siltuma karte",
filters_section: "Filtri", without_coords_btn: "Bez koordinātām",
@ -761,6 +780,7 @@
et: {
search_placeholder: "Otsi (nimi, žanr, linn, riik)…",
truncated_hint: "Kuvatakse ainult selle piirkonna esimesed {n} tulemust — suumi sisse, et näha kõiki.",
displayed: "kuvatud", total_bands: "kokku", localities: "asulatest", located: "lokaliseeritud",
view_section: "Vaade", reset_view: "Lähtesta vaade", heatmap_label: "Soojuskaart",
filters_section: "Filtrid", without_coords_btn: "Ilma koordinaatideta",
@ -798,6 +818,7 @@
nb: {
search_placeholder: "Søk (navn, sjanger, by, land)…",
truncated_hint: "Bare de første {n} resultatene i dette området vises — zoom inn for å se alle.",
displayed: "vist", total_bands: "totalt", localities: "steder", located: "lokaliserte",
view_section: "Visning", reset_view: "Tilbakestill visning", heatmap_label: "Varmekart",
filters_section: "Filtre", without_coords_btn: "Uten koordinater",

View file

@ -1399,3 +1399,13 @@ a:hover {
min-height: 300px;
}
}
/* Signale que la carte ne montre qu'une partie des résultats de la zone.
Sans ça, la troncature du serveur était totalement invisible. */
.trunc-note {
margin-left: 6px;
color: #e0a144;
cursor: help;
font-weight: 700;
}
.trunc-note[hidden] { display: none; }

357
package-lock.json generated
View file

@ -31,6 +31,7 @@
"name": "bm-api",
"version": "0.0.1",
"dependencies": {
"@fastify/compress": "^7.0.3",
"@fastify/cookie": "^9.4.0",
"@fastify/helmet": "^11.1.1",
"@fastify/rate-limit": "^9.0.0",
@ -40,6 +41,31 @@
"pg": "^8.12.0"
}
},
"apps/api/node_modules/@fastify/accept-negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
"integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"apps/api/node_modules/@fastify/compress": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-7.0.3.tgz",
"integrity": "sha512-xa9fo5/DgK1s0bkS6xrYgNn8HmofO5tJvbCDk8QuXshSgLd2cFZANv1ox/Qv7zswS7JroHwTlCVv/XGTVO98tg==",
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^1.1.0",
"fastify-plugin": "^4.5.0",
"mime-db": "^1.52.0",
"minipass": "^7.0.2",
"peek-stream": "^1.1.3",
"pump": "^3.0.0",
"pumpify": "^2.0.1",
"readable-stream": "^4.5.2"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@ -2637,6 +2663,18 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@ -2824,6 +2862,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
@ -2901,12 +2959,42 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -3111,6 +3199,12 @@
"node": ">=6.6.0"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3246,6 +3340,48 @@
"node": ">= 0.4"
}
},
"node_modules/duplexify": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
"integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.0.0",
"inherits": "^2.0.1",
"readable-stream": "^2.0.0",
"stream-shift": "^1.0.0"
}
},
"node_modules/duplexify/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/duplexify/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/duplexify/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -3276,6 +3412,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@ -3636,6 +3781,24 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/execa": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.4.1.tgz",
@ -4276,6 +4439,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -4317,7 +4500,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ipaddr.js": {
@ -4408,6 +4590,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -4837,7 +5025,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -4883,7 +5070,6 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
@ -5038,6 +5224,15 @@
"node": ">=14.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -5205,6 +5400,17 @@
"node": ">= 14.16"
}
},
"node_modules/peek-stream": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz",
"integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==",
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"duplexify": "^3.5.0",
"through2": "^2.0.3"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
@ -5495,6 +5701,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
@ -5524,6 +5745,53 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/pumpify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz",
"integrity": "sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==",
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.1",
"inherits": "^2.0.3",
"pump": "^3.0.0"
}
},
"node_modules/pumpify/node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/pumpify/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -5574,6 +5842,22 @@
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@ -5943,6 +6227,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -6156,6 +6455,46 @@
"real-require": "^0.2.0"
}
},
"node_modules/through2": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
"license": "MIT",
"dependencies": {
"readable-stream": "~2.3.6",
"xtend": "~4.0.1"
}
},
"node_modules/through2/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/through2/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/through2/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -6430,6 +6769,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@ -6781,6 +7126,12 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",