feat: Frontend im Produktionsbetrieb ausliefern, TTFT bei OpenRouter

npm start baute web/dist, aber niemand lieferte es aus — der Server stellte
nur die API bereit. Jetzt registriert er @fastify/static, wenn ein Build
vorliegt, und die App läuft komplett unter :8788. Ohne web/dist bleibt alles
wie bisher (Vite übernimmt im Dev-Betrieb); der Start sagt, welcher Fall
vorliegt. /api und /ws behalten Vorrang, unbekannte /api-Pfade antworten
weiterhin mit JSON statt mit index.html.

OpenRouter, beim Verifizieren gegen ein Gratis-Modell gefunden:
- ttftMs wurde nie gesetzt — evalMs wurde aus firstTokenAt gerechnet, das
  Feld selbst blieb null
- die Zeitmessung startete erst nach dem fetch und ließ damit die Wartezeit
  auf den Anbieter aus. Startet jetzt davor, wie bei Ollama

Geprüft im Produktionsmodus: /, /assets/*.js, SPA-Fallback, JSON-404 auf
/api, WebSocket aus Origin :8788 und :5174 verbunden, fremde Origin 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh
This commit is contained in:
Jeuner 2026-08-28 14:59:35 +02:00
parent ed3c01303b
commit da1d8be426
5 changed files with 309 additions and 9 deletions

View file

@ -26,7 +26,8 @@ npm-Workspace mit zwei Paketen:
| `web/` | React 18 + Vite Frontend, Markdown-Rendering, Voice-Recording |
Der Chat läuft über einen WebSocket (`/ws`), alles andere über REST. Chats,
Nachrichten, Anhänge und Gedächtnis liegen in `server/data.sqlite`.
Nachrichten, Anhänge und Gedächtnis liegen in `server/data.sqlite`. Im
Produktionsbetrieb liefert der Server das gebaute Frontend gleich mit aus.
## Voraussetzungen
@ -364,14 +365,16 @@ läuft die App unter `http://localhost:5174` aus einer Herkunft.
## Produktion
```bash
npm start # baut web/dist und startet den Server
npm start # baut web/dist und startet den Server auf :8788
```
**Hinweis:** Der Server liefert das gebaute Frontend derzeit *nicht* aus — er
stellt nur die API auf `:8788` bereit, `web/dist` bleibt liegen. Für einen
Produktivbetrieb braucht es einen statischen Server davor, der `web/dist`
ausliefert und `/api` sowie `/ws` an `127.0.0.1:8788` weiterreicht. Für den
Alltagsbetrieb auf dem eigenen Rechner ist `npm run dev` der vorgesehene Weg.
Der Server liefert das gebaute Frontend mit aus — die App läuft dann
vollständig unter `http://localhost:8788`, ohne zweiten Prozess. Fehlt
`web/dist`, stellt er nur die API bereit und sagt das beim Start; das ist der
Normalfall im Entwicklungsbetrieb, wo Vite das Frontend übernimmt.
`/api` und `/ws` behalten Vorrang vor den statischen Dateien. Unbekannte
`/api`-Pfade antworten mit JSON-404, alles andere bekommt `index.html`.
Der Server bindet ausschließlich an `127.0.0.1`; zum Aussetzen ins Netz siehe
[Sicherheit](#sicherheit).

267
package-lock.json generated
View file

@ -739,6 +739,22 @@
"node": ">=18"
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz",
"integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
@ -870,6 +886,70 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/send": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz",
"integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz",
"integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@fastify/static/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -920,6 +1000,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
@ -1824,6 +1913,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
@ -1850,6 +1948,18 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@ -1974,6 +2084,19 @@
"node": ">=18"
}
},
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -2087,6 +2210,15 @@
"turndown": "^7.2.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -2247,6 +2379,12 @@
"node": ">=6"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
@ -2463,6 +2601,23 @@
"node": ">=6.9.0"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
@ -2655,6 +2810,32 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@ -3753,6 +3934,42 @@
],
"license": "MIT"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mnemonist": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
@ -3853,6 +4070,31 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
@ -4313,6 +4555,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@ -4351,6 +4599,15 @@
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -4437,6 +4694,15 @@
"node": ">=20"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@ -5282,6 +5548,7 @@
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/static": "^10.1.3",
"defuddle": "^0.19.3",
"fastify": "^5.2.1",
"linkedom": "^0.18.13",

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/static": "^10.1.3",
"defuddle": "^0.19.3",
"fastify": "^5.2.1",
"linkedom": "^0.18.13",

View file

@ -1,9 +1,10 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "node:http";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import * as dbmod from "./db.js";
import * as mem from "./memory.js";
@ -309,6 +310,25 @@ app.get("/api/ollama/models", async () => {
}
});
// --- Gebautes Frontend ---
//
// Nur wenn web/dist existiert: im Entwicklungsbetrieb liefert der
// Vite-Server das Frontend aus, dann soll hier nichts danebenstehen.
// Registrierung nach den API-Routen, damit /api und /ws Vorrang behalten.
const WEB_DIST = path.join(import.meta.dirname, "..", "..", "web", "dist");
const hasBuild = existsSync(path.join(WEB_DIST, "index.html"));
if (hasBuild) {
await app.register(fastifyStatic, { root: WEB_DIST });
app.setNotFoundHandler((req, reply) => {
// API-Fehler bleiben JSON; alles andere bekommt die App.
if (req.url.startsWith("/api")) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
}
// --- WebSocket ---
interface ChatOptionsPayload {
think: boolean;
@ -677,4 +697,9 @@ server.on("upgrade", (req, socket, head) => {
app.listen({ port: PORT, host: "127.0.0.1" }, () => {
console.log(`[agenttwo-tools] Server läuft auf http://127.0.0.1:${PORT}`);
console.log(
hasBuild
? "[agenttwo-tools] Frontend aus web/dist wird mit ausgeliefert"
: "[agenttwo-tools] Kein web/dist — Frontend über 'npm run dev:web' (:5174)",
);
});

View file

@ -76,6 +76,10 @@ export async function streamOpenRouter(
cb: StreamCallbacks,
signal: AbortSignal,
): Promise<void> {
// Vor dem fetch: TTFT soll die Wartezeit auf den Anbieter enthalten, nicht
// erst ab dem Eintreffen der Antwort-Header zählen (so misst es auch Ollama).
const startedAt = Date.now();
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
@ -108,7 +112,6 @@ export async function streamOpenRouter(
const decoder = new TextDecoder();
let buffer = "";
const startedAt = Date.now();
let firstTokenAt: number | null = null;
const stats: ChatStats = {
promptTokens: 0,
@ -125,6 +128,7 @@ export async function streamOpenRouter(
*/
function finish(): void | Promise<void> {
stats.totalMs = Date.now() - startedAt;
stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt;
stats.evalMs = firstTokenAt === null ? 0 : Date.now() - firstTokenAt;
return cb.onStats?.(stats);
}