release: git-arboretum 3.5.0 (cache SPA, copier/coller terminal, fichiers des groupes), desktop 0.2.1
CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 10m50s
CI / Build & test (Node 22) (push) Successful in 11m2s
Release / Publish to Gitea npm registry (push) Successful in 10m56s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m21s
Desktop Release / Publish floating desktop-latest release (push) Successful in 11s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m53s
Desktop Release / Build Windows (NSIS + portable) (push) Canceled after 0s
CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 10m50s
CI / Build & test (Node 22) (push) Successful in 11m2s
Release / Publish to Gitea npm registry (push) Successful in 10m56s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m21s
Desktop Release / Publish floating desktop-latest release (push) Successful in 11s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m53s
Desktop Release / Build Windows (NSIS + portable) (push) Canceled after 0s
Cause racine de l'ecran noir apres mise a jour : l'etag faible de @fastify/static derive de taille+mtime, et npm pack fige le mtime de tout le tarball a une date constante (1985-10-26). Deux index.html de versions differentes mais de meme taille partageaient donc le meme etag : le client recevait un 304, gardait son index perime, et demandait des /assets/<hash> disparus ; le fallback SPA repondait index.html en text/html pour ces modules, le navigateur refusait le script, rien ne peignait. - fix(server): index.html et tous les fichiers non haches servis en no-store, validation conditionnelle desactivee (etag/lastModified) pour qu'un client bloque sur un index perime se repare seul ; /assets/ (noms haches) passent en immutable un an - feat(web): copier/coller dans le terminal xterm, dont la selection n'est pas une selection DOM : Ctrl+Maj+C / Ctrl+Maj+V, Cmd+C / Cmd+V sur macOS, Ctrl+Inser / Maj+Inser, plus interception de l'evenement DOM copy pour que le Copier natif fonctionne. Ctrl+C reste SIGINT - fix(web): script anti-FOUC sorti dans /theme-boot.js, la CSP script-src 'self' du daemon refusait de l'executer inline (le theme n'etait donc pose qu'au montage de la SPA) - feat(web): les worktrees d'un groupe se deplient sur leur arborescence de fichiers dans le panneau Groupes (meme composant et meme etat d'expansion que l'Explorateur), et un worktree ainsi deplie est desormais surveille en temps reel - fix: octet nul litteral remplace par \0 dans trois sources (stores/ide.ts, GitPanel.vue, vscode/repos-tree.ts) : git et grep les traitaient comme binaires, leurs diffs etaient illisibles en revue et la garde CI lint-dashes (git grep -I) les sautait en silence - test: cacheControlFor et clipboardIntent en tests purs, verify-clipboard.mjs (E2E Chromium CDP : copie, collage et SIGINT prouves par le presse-papier reel et par le systeme de fichiers), capture groups-dark-desktop ajoutee a verify-ui.mjs
This commit is contained in:
@@ -3,6 +3,33 @@
|
||||
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
||||
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
||||
|
||||
## 3.5.0
|
||||
|
||||
Fixes a black screen after every update, gives the web terminal a working copy & paste, and lets you
|
||||
browse a group's files without leaving the Groups panel. Fully additive, no protocol version bump.
|
||||
|
||||
- **No more black screen after an update.** The embedded SPA is served by `@fastify/static`, whose weak
|
||||
etag derives from size + mtime, and `npm pack` pins the mtime of every file in the tarball to a
|
||||
constant (1985-10-26). Two different `index.html` of equal size therefore shared an etag: clients got a
|
||||
`304 Not Modified` and kept an index referencing `/assets/<hash>` files that no longer existed. The
|
||||
fallback route then answered those module requests with `index.html` as `text/html`, the browser
|
||||
refused the script, and nothing painted. `index.html` and every other unhashed file are now served
|
||||
`no-store` with conditional validation disabled, so a client holding a stale copy repairs itself;
|
||||
hashed `/assets/` are served `immutable` for a year.
|
||||
- **Copy & paste in the terminal.** xterm's selection is not a DOM selection, so the native Copy (the
|
||||
Electron Edit menu, the browser context menu) had nothing to copy and terminal output could not be
|
||||
retrieved at all. `Ctrl+Shift+C` / `Ctrl+Shift+V` (`Cmd+C` / `Cmd+V` on macOS, plus
|
||||
`Ctrl+Insert` / `Shift+Insert`) now copy the selection and paste the clipboard, and the DOM `copy`
|
||||
event is intercepted so the native Copy works too. `Ctrl+C` is deliberately untouched: it stays SIGINT.
|
||||
- **Theme applied before the first paint again.** The anti-FOUC script was inline in `index.html`, which
|
||||
the daemon's own CSP (`script-src 'self'`) refused to execute; it moved to `/theme-boot.js`.
|
||||
- **Browse files from the Groups panel.** A group's worktrees expand into their file tree, the same
|
||||
component and the same expansion state as the Explorer, and those worktrees are now watched for
|
||||
real-time changes too.
|
||||
- **Sources are text again.** Three files embedded a literal NUL byte in a string separator, which made
|
||||
git and grep treat them as binary: their diffs were unreviewable and the `lint-dashes` CI guard
|
||||
(`git grep -I`) silently skipped them. Escaped as `\0`, same runtime value.
|
||||
|
||||
## 3.4.0
|
||||
|
||||
Visibility release: the real-time machinery is now actually armed, worktrees show what they are worth,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "3.4.0",
|
||||
"version": "3.5.0",
|
||||
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
// Vérification E2E du copier / coller dans le terminal web (régression : la sélection d'xterm n'est
|
||||
// pas une sélection DOM, le « Copier » natif ne voyait donc rien). Daemon temporaire isolé + session
|
||||
// `bash` (pas `claude` : pas de quota consommé) + Chromium piloté en CDP : on tape un marqueur, on le
|
||||
// sélectionne à la souris, Ctrl+Shift+C, et on relit le presse-papier réel du navigateur. Puis
|
||||
// l'inverse : on remplit le presse-papier, Ctrl+Shift+V, et on vérifie que le PTY l'a reçu.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
const PORT = 7411;
|
||||
const CDP_PORT = 9334;
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let failures = 0;
|
||||
function check(label, ok, detail = '') {
|
||||
console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` : ${detail}` : ''}`);
|
||||
if (!ok) failures++;
|
||||
}
|
||||
|
||||
function findChromium() {
|
||||
for (const bin of ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome']) {
|
||||
if (existsSync(bin)) return bin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Client CDP minimal : un socket, corrélation par id. */
|
||||
function cdp(url) {
|
||||
const ws = new WebSocket(url, { perMessageDeflate: false });
|
||||
const pending = new Map();
|
||||
let seq = 0;
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
ws.once('open', resolve);
|
||||
ws.once('error', reject);
|
||||
});
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
const entry = pending.get(msg.id);
|
||||
if (!entry) return;
|
||||
pending.delete(msg.id);
|
||||
msg.error ? entry.reject(new Error(msg.error.message)) : entry.resolve(msg.result);
|
||||
});
|
||||
return {
|
||||
ready,
|
||||
close: () => ws.close(),
|
||||
send(method, params = {}, sessionId) {
|
||||
const id = ++seq;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error(`CDP timeout: ${method}`))), 30_000);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let srv, browser, tmp;
|
||||
try {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'arb-clip-'));
|
||||
|
||||
srv = spawn(
|
||||
'node',
|
||||
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 't.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
|
||||
{ env: { ...process.env, XDG_DATA_HOME: join(tmp, 'xdg'), ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
let srvOut = '';
|
||||
srv.stdout.on('data', (d) => (srvOut += d));
|
||||
srv.stderr.on('data', (d) => (srvOut += d));
|
||||
for (let i = 0; i < 60 && !/arb_[0-9a-f]{16,}/.test(srvOut); i++) await sleep(150);
|
||||
const token = /arb_[0-9a-f]{16,}/.exec(srvOut)?.[0];
|
||||
check('daemon temporaire démarré', !!token);
|
||||
|
||||
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const cookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
|
||||
const cookieValue = cookie?.slice('arb_session='.length) ?? '';
|
||||
check('login → cookie de session', !!cookie);
|
||||
|
||||
const sess = await (
|
||||
await fetch(`${ORIGIN}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { Origin: ORIGIN, Cookie: cookie, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cwd: tmp, command: 'bash' }),
|
||||
})
|
||||
).json();
|
||||
const sessionId = sess.session?.id;
|
||||
check('session bash lancée', !!sessionId);
|
||||
|
||||
const chromeBin = findChromium();
|
||||
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
|
||||
if (!chromeBin || !sessionId) throw new Error('prérequis manquants');
|
||||
|
||||
browser = spawn(
|
||||
chromeBin,
|
||||
[
|
||||
'--headless=new',
|
||||
`--remote-debugging-port=${CDP_PORT}`,
|
||||
`--user-data-dir=${join(tmp, 'chrome')}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-gpu',
|
||||
'--hide-scrollbars',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
let wsUrl = null;
|
||||
for (let i = 0; i < 80 && !wsUrl; i++) {
|
||||
await sleep(200);
|
||||
try {
|
||||
wsUrl = (await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json()).webSocketDebuggerUrl;
|
||||
} catch {
|
||||
/* pas encore prêt */
|
||||
}
|
||||
}
|
||||
check('Chromium en écoute CDP', !!wsUrl);
|
||||
|
||||
const client = cdp(wsUrl);
|
||||
await client.ready;
|
||||
// Presse-papier lisible/écrivable sans geste utilisateur (sinon readText() rejette en headless).
|
||||
await client.send('Browser.grantPermissions', {
|
||||
origin: ORIGIN,
|
||||
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
||||
});
|
||||
|
||||
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
||||
const { sessionId: sid } = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
||||
await client.send('Page.enable', {}, sid);
|
||||
await client.send('Runtime.enable', {}, sid);
|
||||
await client.send('Network.enable', {}, sid);
|
||||
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sid);
|
||||
await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, sid);
|
||||
|
||||
const evaluate = async (expression, awaitPromise = false) =>
|
||||
(await client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }, sid)).result?.value;
|
||||
|
||||
await client.send('Page.navigate', { url: `${ORIGIN}/sessions/${sessionId}` }, sid);
|
||||
// attend que xterm soit monté ET que bash ait rendu son invite
|
||||
let screen = null;
|
||||
for (let i = 0; i < 80 && !screen; i++) {
|
||||
await sleep(250);
|
||||
screen = await evaluate(`(() => { const el = document.querySelector('.xterm-screen'); if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 50 ? JSON.stringify(r) : null; })()`);
|
||||
}
|
||||
check('terminal xterm monté', !!screen);
|
||||
const rect = screen ? JSON.parse(screen) : null;
|
||||
|
||||
// Le renderer WebGL peint dans un canvas : `.xterm-rows` est vide, on ne peut RIEN vérifier via le
|
||||
// DOM. Les preuves passent donc par le système de fichiers (le cwd de la session est `tmp`) et par
|
||||
// le presse-papier réel du navigateur.
|
||||
const focusTerm = () => client.send('Runtime.evaluate', { expression: `document.querySelector('.xterm-helper-textarea')?.focus()` }, sid);
|
||||
const pressEnter = async () => {
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sid);
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sid);
|
||||
};
|
||||
const waitForFile = async (name, tries = 40) => {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
if (existsSync(join(tmp, name))) return true;
|
||||
await sleep(200);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// --- Frappe dans le PTY (Input.insertText → textarea xterm → stdin) ---
|
||||
await focusTerm();
|
||||
await client.send('Input.insertText', { text: 'touch typed-ok' }, sid);
|
||||
await pressEnter();
|
||||
check('le PTY exécute une commande tapée au clavier', await waitForFile('typed-ok'));
|
||||
|
||||
// Marqueur affiché à l'écran, cible de la copie
|
||||
const MARKER = 'COPIE_MOI_4242';
|
||||
await client.send('Input.insertText', { text: `echo ${MARKER}` }, sid);
|
||||
await pressEnter();
|
||||
await sleep(600);
|
||||
|
||||
// --- Sélection à la souris sur la zone du terminal, puis Ctrl+Shift+C ---
|
||||
if (rect) {
|
||||
const y = rect.y + 8;
|
||||
await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x + 2, y, button: 'left', clickCount: 1, buttons: 1 }, sid);
|
||||
await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + rect.width - 4, y: y + 40, button: 'left', buttons: 1 }, sid);
|
||||
await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + rect.width - 4, y: y + 40, button: 'left', clickCount: 1, buttons: 0 }, sid);
|
||||
}
|
||||
await sleep(300);
|
||||
const selection = await evaluate(`(() => { const s = document.querySelector('.xterm')?.classList; return document.getSelection()?.toString() ?? ''; })()`);
|
||||
// ctrl(2) + shift(8) = 10
|
||||
const keyOpts = { modifiers: 10, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'C', code: 'KeyC' };
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...keyOpts }, sid);
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...keyOpts }, sid);
|
||||
await sleep(500);
|
||||
const copied = (await evaluate('navigator.clipboard.readText()', true)) ?? '';
|
||||
check('Ctrl+Shift+C copie la sélection du terminal', copied.includes(MARKER), JSON.stringify(copied.slice(0, 60)));
|
||||
|
||||
// --- Collage : presse-papier → Ctrl+Shift+V → la commande collée doit atteindre le PTY ---
|
||||
await evaluate(`navigator.clipboard.writeText('touch paste-ok')`, true);
|
||||
await focusTerm();
|
||||
const vOpts = { modifiers: 10, windowsVirtualKeyCode: 86, nativeVirtualKeyCode: 86, key: 'V', code: 'KeyV' };
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...vOpts }, sid);
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...vOpts }, sid);
|
||||
await sleep(400);
|
||||
await pressEnter();
|
||||
check('Ctrl+Shift+V colle le presse-papier dans le terminal', await waitForFile('paste-ok'));
|
||||
|
||||
// --- Ctrl+C ne doit PAS être détourné : il reste SIGINT ---
|
||||
// `sleep 25` bloque le shell ; si le ^C passe, le shell reprend et exécute la commande suivante.
|
||||
await focusTerm();
|
||||
await client.send('Input.insertText', { text: 'sleep 25' }, sid);
|
||||
await pressEnter();
|
||||
await sleep(700);
|
||||
const cOpts = { modifiers: 2, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'c', code: 'KeyC' };
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...cOpts }, sid);
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...cOpts }, sid);
|
||||
await sleep(500);
|
||||
await client.send('Input.insertText', { text: 'touch interrupt-ok' }, sid);
|
||||
await pressEnter();
|
||||
check('Ctrl+C reste transmis au PTY (SIGINT, pas une copie)', await waitForFile('interrupt-ok', 30));
|
||||
|
||||
client.close();
|
||||
} catch (err) {
|
||||
check('exécution du scénario', false, err?.message ?? String(err));
|
||||
} finally {
|
||||
browser?.kill('SIGKILL');
|
||||
srv?.kill('SIGKILL');
|
||||
await sleep(300);
|
||||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nVERIFY CLIPBOARD: ALL GREEN' : `\nVERIFY CLIPBOARD: ${failures} ÉCHEC(S)`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -168,12 +168,19 @@ try {
|
||||
const context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo }));
|
||||
const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`;
|
||||
const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');localStorage.setItem('arb.history.open', 'true');`;
|
||||
// Panneau Groupes avec le groupe ET le worktree dépliés : c'est la vue qui porte l'arborescence de
|
||||
// fichiers des membres du groupe, sinon jamais capturée.
|
||||
const seedGroups =
|
||||
`${seedExplorer}localStorage.setItem('arb.ide.activity', '"groups"');` +
|
||||
`localStorage.setItem('arb.ide.expandedGroups', ${JSON.stringify(JSON.stringify([groupRes.group?.id]))});` +
|
||||
`localStorage.setItem('arb.ide.expandedWts', ${JSON.stringify(JSON.stringify([repo]))});`;
|
||||
|
||||
const shots = [
|
||||
{ name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer },
|
||||
{ name: 'ide-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedExplorer },
|
||||
{ name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit },
|
||||
{ name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit },
|
||||
{ name: 'groups-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGroups },
|
||||
{ name: 'ide-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer },
|
||||
{ name: 'ide-light-mobile', theme: 'light', width: 390, height: 844, seed: seedExplorer },
|
||||
{ name: 'dashboard-dark-mobile', theme: 'dark', width: 390, height: 844, path: '/dashboard' },
|
||||
|
||||
@@ -64,6 +64,22 @@ const SECURITY_HEADERS: Record<string, string> = {
|
||||
].join('; '),
|
||||
};
|
||||
|
||||
// Politique de cache du statique. Piège à connaître : `npm pack` normalise le mtime de TOUS les
|
||||
// fichiers du tarball à une date constante (1985-10-26). L'etag faible de @fastify/static étant
|
||||
// dérivé de taille+mtime, deux versions différentes d'un même fichier non haché produisent le
|
||||
// MÊME etag dès que leur taille coïncide : le client reçoit un 304 et garde indéfiniment
|
||||
// l'ancienne copie. Vécu en production sur index.html à la mise à jour de l'app desktop : l'index
|
||||
// obsolète référençait des `/assets/<hash>.js` disparus, le fallback SPA répondait du text/html
|
||||
// pour ces modules, et la page restait noire.
|
||||
// Conséquence : seuls les fichiers dont le NOM porte un hash de contenu (/assets/) sont
|
||||
// cachables ; tout le reste (index.html, sw.js, theme-boot.js, manifest, icônes) part en
|
||||
// no-store, la revalidation par etag n'étant pas fiable ici.
|
||||
export function cacheControlFor(pathname: string): string {
|
||||
return pathname.startsWith('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-store';
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authContext: AuthContext | null;
|
||||
@@ -220,7 +236,25 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
// SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front)
|
||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
void app.register(fastifyStatic, { root: publicDir, wildcard: false });
|
||||
void app.register(fastifyStatic, {
|
||||
root: publicDir,
|
||||
wildcard: false,
|
||||
// Validation conditionnelle désactivée : l'etag faible et le Last-Modified dérivent du mtime,
|
||||
// que `npm pack` fige (cf. cacheControlFor). Les laisser actifs ferait répondre 304 aux
|
||||
// clients qui détiennent encore un index.html périmé d'une version antérieure : ils y
|
||||
// resteraient bloqués. Sans etag, ils reçoivent un 200 et se réparent d'eux-mêmes. Le coût
|
||||
// est nul pour /assets (noms hachés, servis immutable) et négligeable ailleurs.
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
// Indispensable : sinon le plugin écrit son propre `cache-control: public, max-age=0`
|
||||
// APRÈS setHeaders et écrase le no-store ci-dessous.
|
||||
cacheControl: false,
|
||||
// `setHeaders` s'applique aussi aux `reply.sendFile` du fallback SPA ci-dessous.
|
||||
setHeaders(res, path) {
|
||||
const rel = path.slice(publicDir.length).replace(/\\/g, '/');
|
||||
res.setHeader('Cache-Control', cacheControlFor(rel));
|
||||
},
|
||||
});
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cacheControlFor } from '../src/app.js';
|
||||
|
||||
// Régression : à la mise à jour de l'app desktop, l'index.html mis en cache par le client était
|
||||
// revalidé en 304 (etag = taille+mtime, mtime figé à 1985 par npm pack dans le tarball) et
|
||||
// continuait donc de référencer des /assets/<hash> disparus → modules servis en text/html par le
|
||||
// fallback SPA → page noire. Seuls les noms hachés sont cachables.
|
||||
describe('cacheControlFor', () => {
|
||||
it('rend les assets hachés cachables indéfiniment', () => {
|
||||
expect(cacheControlFor('/assets/index-94trXkeo.js')).toBe('public, max-age=31536000, immutable');
|
||||
expect(cacheControlFor('/assets/inter-latin-wght-normal-Dx4kXJAl.woff2')).toBe(
|
||||
'public, max-age=31536000, immutable',
|
||||
);
|
||||
});
|
||||
|
||||
it("interdit la mise en cache de l'index.html", () => {
|
||||
expect(cacheControlFor('/index.html')).toBe('no-store');
|
||||
});
|
||||
|
||||
it('interdit la mise en cache des fichiers racine non hachés', () => {
|
||||
for (const p of ['/sw.js', '/theme-boot.js', '/manifest.webmanifest', '/favicon.ico', '/icon-512.png']) {
|
||||
expect(cacheControlFor(p)).toBe('no-store');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user