From 28c7a90d8b5f35d7b3db5d6eec743ec647152bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:58:10 +0000 Subject: [PATCH] Analytics: Skripte ausliefern Co-Authored-By: Claude Sonnet 5.5 --- analytics/src/assets.js | 60 ++++++++++++++++++++ analytics/src/server.js | 5 +- analytics/test/assets.test.js | 104 ++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 analytics/src/assets.js create mode 100644 analytics/test/assets.test.js diff --git a/analytics/src/assets.js b/analytics/src/assets.js new file mode 100644 index 0000000..7f48fb2 --- /dev/null +++ b/analytics/src/assets.js @@ -0,0 +1,60 @@ +// GET/HEAD /a/w.js und /a/h.js (öffentlicher Listener): generierte Client-Skripte aus client/dist/. +// Einmal beim Start in den Speicher geladen (roh + gzip); fehlt eine Datei, antwortet der Pfad mit 503. +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import crypto from 'node:crypto'; + +const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist'); +const FILES = { '/a/w.js': 'w.js', '/a/h.js': 'h.js' }; + +export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) { + const assets = new Map(); + for (const [path, file] of Object.entries(FILES)) { + try { + const raw = readFileSync(join(distDir, file)); + assets.set(path, { raw, gz: gzipSync(raw, { level: 9 }), etag: `"${crypto.createHash('sha256').update(raw).digest('hex')}"` }); + } catch (e) { + log.error(`Analytics: Skript ${file} fehlt in ${distDir} (${e.code || e.message}); ${path} antwortet mit 503`); + } + } + + // true, wenn die Anfrage ein Skript betraf. + return function handle(req, res, pathname) { + if (!(pathname in FILES)) return false; + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { Allow: 'GET, HEAD' }); + res.end(); + return true; + } + const a = assets.get(pathname); + if (!a) { + res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(req.method === 'HEAD' ? '' : JSON.stringify({ error: 'Skript nicht verfügbar' })); + return true; + } + const headers = { + 'Content-Type': 'text/javascript; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + ETag: a.etag, + Vary: 'Accept-Encoding', + 'X-Content-Type-Options': 'nosniff', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'Access-Control-Allow-Origin': '*', + }; + const inm = req.headers['if-none-match']; + if (inm && inm.split(',').some((t) => t.trim().replace(/^W\//, '') === a.etag)) { + res.writeHead(304, headers); + res.end(); + return true; + } + const gzip = /\bgzip\b/i.test(req.headers['accept-encoding'] || ''); + const body = gzip ? a.gz : a.raw; + if (gzip) headers['Content-Encoding'] = 'gzip'; + headers['Content-Length'] = body.length; + res.writeHead(200, headers); + res.end(req.method === 'HEAD' ? undefined : body); + return true; + }; +} diff --git a/analytics/src/server.js b/analytics/src/server.js index d9cc76e..db2d0e8 100644 --- a/analytics/src/server.js +++ b/analytics/src/server.js @@ -4,6 +4,7 @@ import { createIngest } from './ingest.js'; import { createSalt } from './salt.js'; import { createHeat } from './heat.js'; import { computeStats } from './stats.js'; +import { createAssets } from './assets.js'; const MAX_ADMIN_BODY = 64 * 1024; const SITE_KEY = /^[a-z0-9]{16,40}$/; @@ -60,6 +61,7 @@ function validateSite(b) { function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) { const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null; const heat = role === 'public' ? createHeat({ db, config, now }) : null; + const assets = role === 'public' ? createAssets({ distDir: config.distDir }) : null; const clock = now || Date.now; const server = http.createServer(async (req, res) => { try { @@ -69,6 +71,7 @@ function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged if (ingest && await ingest(req, res, url.pathname)) return; if (heat && heat(req, res, url)) return; + if (assets && assets(req, res, url.pathname)) return; if (method === 'GET' && url.pathname === '/a/health') return send(res, 200, { ok: true }); @@ -127,7 +130,7 @@ function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged return server; } -// Öffentlicher Listener: /a/health und /a/e (Erfassung); Admin-Routen gibt es hier nicht. +// Öffentlicher Listener: /a/health, /a/e (Erfassung), /a/heat, /a/w.js und /a/h.js; Admin-Routen gibt es hier nicht. export const createPublicServer = ({ config, db, geo, salt, now }) => createHandlerServer({ config, db, role: 'public', geo, salt, now }); // Interner Listener für die Website-Verwaltung. // onSitesChanged: wird nach jeder Änderung an Websites gerufen (public.invalidateSites()). diff --git a/analytics/test/assets.test.js b/analytics/test/assets.test.js new file mode 100644 index 0000000..5627b8d --- /dev/null +++ b/analytics/test/assets.test.js @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import http from 'node:http'; +import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { gunzipSync } from 'node:zlib'; +import { openDb } from '../src/db.js'; +import { createPublicServer, createAdminServer } from '../src/server.js'; + +const DIST = new URL('../client/dist/', import.meta.url); + +async function start(config = {}) { + const server = createPublicServer({ config, db: openDb(':memory:') }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }; +} + +// fetch dekodiert gzip selbst; für den rohen Body http.request verwenden. +function raw(base, path, headers = {}, method = 'GET') { + return new Promise((resolve, reject) => { + const req = http.request(base + path, { method, headers }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })); + }); + req.on('error', reject); + req.end(); + }); +} + +for (const file of ['w.js', 'h.js']) { + test(`/a/${file}: Header, identity und gzip liefern den dist-Inhalt`, async () => { + const s = await start(); + const want = readFileSync(new URL(file, DIST)); + const etag = `"${crypto.createHash('sha256').update(want).digest('hex')}"`; + const id = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'identity' }); + assert.equal(id.status, 200); + assert.deepEqual(id.body, want); + assert.equal(id.headers['content-type'], 'text/javascript; charset=utf-8'); + assert.equal(id.headers['cache-control'], 'public, max-age=3600'); + assert.equal(id.headers.etag, etag); + assert.equal(id.headers.vary, 'Accept-Encoding'); + assert.equal(id.headers['x-content-type-options'], 'nosniff'); + assert.equal(id.headers['cross-origin-resource-policy'], 'cross-origin'); + assert.equal(id.headers['access-control-allow-origin'], '*'); + assert.equal(id.headers['content-encoding'], undefined); + const gz = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'gzip, deflate' }); + assert.equal(gz.status, 200); + assert.equal(gz.headers['content-encoding'], 'gzip'); + assert.equal(gz.headers.etag, etag); + assert.ok(gz.body.length < want.length); + assert.deepEqual(gunzipSync(gz.body), want); + s.close(); + }); +} + +test('ETag: 304 bei If-None-Match, 200 bei anderem Wert', async () => { + const s = await start(); + const first = await raw(s.base, '/a/w.js'); + const hit = await raw(s.base, '/a/w.js', { 'If-None-Match': first.headers.etag }); + assert.equal(hit.status, 304); + assert.equal(hit.body.length, 0); + assert.equal(hit.headers.etag, first.headers.etag); + assert.equal(hit.headers['cache-control'], 'public, max-age=3600'); + assert.equal((await raw(s.base, '/a/w.js', { 'If-None-Match': '"anders"' })).status, 200); + s.close(); +}); + +test('HEAD: Header wie GET, kein Body', async () => { + const s = await start(); + const get = await raw(s.base, '/a/h.js', { 'Accept-Encoding': 'identity' }); + const head = await raw(s.base, '/a/h.js', { 'Accept-Encoding': 'identity' }, 'HEAD'); + assert.equal(head.status, 200); + assert.equal(head.body.length, 0); + assert.equal(head.headers['content-length'], String(get.body.length)); + assert.equal(head.headers.etag, get.headers.etag); + assert.equal((await raw(s.base, '/a/h.js', {}, 'POST')).status, 405); + s.close(); +}); + +test('Admin-Listener liefert die Skripte nicht', async () => { + const server = createAdminServer({ config: { adminToken: 't' }, db: openDb(':memory:') }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + assert.equal((await fetch(`${base}/a/w.js`)).status, 404); + assert.equal((await fetch(`${base}/a/h.js`)).status, 404); + server.close(); +}); + +test('fehlende dist-Datei: 503 nur für diesen Pfad, deutscher Fehler im Log', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dist-')); + writeFileSync(join(dir, 'w.js'), 'console.log(1)'); + const orig = console.error; + const logged = []; + console.error = (...a) => logged.push(a.join(' ')); + let s; + try { s = await start({ distDir: dir }); } finally { console.error = orig; } + assert.ok(logged.some((l) => l.includes('h.js') && l.includes('fehlt'))); + assert.equal((await fetch(`${s.base}/a/w.js`)).status, 200); + assert.equal((await fetch(`${s.base}/a/h.js`)).status, 503); + s.close(); +});