style: add eslint and prettier

This commit is contained in:
Cfp
2025-06-22 17:06:50 +02:00
parent 6eb465d43a
commit 6ecaef6e8b
11 changed files with 552 additions and 214 deletions

120
src/ai.ts
View File

@ -1,11 +1,9 @@
import { Hono, type Context } from "hono";
import { type Context } from "hono";
import { streamText, tool } from "ai";
import { google } from "@ai-sdk/google";
import { stream } from "hono/streaming";
import z from "zod";
const app = new Hono();
export type OverpassResult = {
elements: OverpassElement[];
};
@ -25,11 +23,7 @@ export type OverpassElement = {
const OVERPASS_SERVER = "https://overpass-api.de/api/interpreter";
export async function fetchPOI(
lat: number,
lon: number,
radius: number,
) {
export async function fetchPOI(lat: number, lon: number, radius: number) {
return await fetch(OVERPASS_SERVER, {
method: "POST",
body: `[out:json];
@ -45,54 +39,70 @@ export async function fetchPOI(
node(around:${radius}, ${lat}, ${lon})["amenity"="parking"];
way(around:${radius}, ${lat}, ${lon})["amenity"="parking"];
);
out center tags;`
}).then(res => res.json() as Promise<OverpassResult>);
out center tags;`,
}).then((res) => res.json() as Promise<OverpassResult>);
}
function getDistance(aLat: number, aLon: number, lat: number, lon: number): number {
function getDistance(
aLat: number,
aLon: number,
lat: number,
lon: number,
): number {
const R = 6371e3; // Earth radius in meters
const φ1 = lat * Math.PI / 180;
const φ2 = aLat * Math.PI / 180;
const Δφ = (aLat - lat) * Math.PI / 180;
const Δλ = (aLon - lon) * Math.PI / 180;
const φ1 = (lat * Math.PI) / 180;
const φ2 = (aLat * Math.PI) / 180;
const Δφ = ((aLat - lat) * Math.PI) / 180;
const Δλ = ((aLon - lon) * Math.PI) / 180;
const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2;
const a =
Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function sortByDistance(elements: OverpassElement[], lat: number, lng: number): OverpassElement[] {
function sortByDistance(
elements: OverpassElement[],
lat: number,
lng: number,
): OverpassElement[] {
return elements.sort((a: OverpassElement, b: OverpassElement) => {
const aLoc = a.center || a;
const bLoc = b.center || b;
return getDistance(aLoc.lat!, aLoc.lon!, lat, lng) - getDistance(bLoc.lat!, bLoc.lon!, lat, lng);
return (
getDistance(aLoc.lat!, aLoc.lon!, lat, lng) -
getDistance(bLoc.lat!, bLoc.lon!, lat, lng)
);
});
}
export async function post(c: Context) {
const body = await c.req.json();
const text = body.text ? body.text.trim() : "";
const coords = body.coords;
let tags: Record<string, string> | undefined = undefined;
if(coords && coords.lat && coords.lon) {
if (coords && coords.lat && coords.lon) {
// fetch tags from OpenStreetMap using Overpass API
console.log("Fetching POI for coordinates:", coords.lat, coords.lon);
const res = await fetchPOI(coords.lat, coords.lon, 100);
const poi = sortByDistance(res.elements, coords.lat, coords.lon);
if(poi.length > 0) {
if (poi.length > 0) {
tags = poi[0]?.tags ?? {}; // Use the first element's tags
coords.lat = poi[0]?.lat ?? coords.lat; // Use the first element's lat if available
coords.lon = poi[0]?.lon ?? coords.lon; // Use the first element's lon if available
}
}
console.log("Received request with text:", text);
const prompt = JSON.stringify({
coords,
tags,
text
}, null, 2)
const prompt = JSON.stringify(
{
coords,
tags,
text,
},
null,
2,
);
console.log("Generated prompt:", prompt);
const result = streamText({
onError: (error) => {
@ -131,23 +141,29 @@ The local date and time is ${new Date().toLocaleString("de-DE", { timeZone: "Eur
maxSteps: 5,
tools: {
overpass: tool({
description: "Query OpenStreetMap data using Overpass API with the given Overpass QL query.",
description:
"Query OpenStreetMap data using Overpass API with the given Overpass QL query.",
parameters: z.object({
query: z.string().describe("The Overpass QL query to execute."),
}),
execute: async ({ query }) => {
console.log("Executing Overpass API query:", query);
const response = await fetch("https://overpass-api.de/api/interpreter", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: query,
});
const response = await fetch(
"https://overpass-api.de/api/interpreter",
{
method: "POST",
headers: { "Content-Type": "text/plain" },
body: query,
},
);
if (!response.ok) {
throw new Error(`Overpass API request failed: ${response.status} ${response.statusText}`);
throw new Error(
`Overpass API request failed: ${response.status} ${response.statusText}`,
);
}
const data = await response.text();
return data;
}
},
}),
fetchWebsite: tool({
description: "Fetch the raw HTML content of a website.",
@ -161,34 +177,36 @@ The local date and time is ${new Date().toLocaleString("de-DE", { timeZone: "Eur
"User-Agent": "Mozilla/5.0 (compatible; GeminiBot/1.0)",
},
});
if (!res.ok) {
throw new Error(`Failed to fetch site: ${res.status} ${res.statusText}`);
throw new Error(
`Failed to fetch site: ${res.status} ${res.statusText}`,
);
}
const text = await res.text();
function stripHTML(html: string): string {
// Remove script/style/head tags and their content
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, '');
html = html.replace(/<head[\s\S]*?>[\s\S]*?<\/head>/gi, '');
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "");
html = html.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "");
html = html.replace(/<head[\s\S]*?>[\s\S]*?<\/head>/gi, "");
// Strip all remaining HTML tags
const text = html.replace(/<\/?[^>]+(>|$)/g, '');
return text.replace(/\s+/g, ' ').trim().slice(0, 4000);
const text = html.replace(/<\/?[^>]+(>|$)/g, "");
return text.replace(/\s+/g, " ").trim().slice(0, 4000);
}
return stripHTML(text).slice(0, 5000); // avoid hitting token limit
},
})
}
})
}),
},
});
// Mark the response as a v1 data stream:
c.header('X-Vercel-AI-Data-Stream', 'v1');
c.header('Content-Type', 'text/plain; charset=utf-8');
c.header("X-Vercel-AI-Data-Stream", "v1");
c.header("Content-Type", "text/plain; charset=utf-8");
return stream(c, stream => stream.pipe(result.toDataStream()));
}
return stream(c, (stream) => stream.pipe(result.toDataStream()));
}

View File

@ -3,19 +3,27 @@ import jwkToPem, { type JWK } from "jwk-to-pem";
const JWKS = process.env.OIDC_JWKS_URL || "";
type JWKSResponse = {
keys: Array<{ kid: string; kty: string; use: string; alg: Algorithm; n: string; e: string }>;
interface JWKSResponse {
keys: {
kid: string;
kty: string;
use: string;
alg: Algorithm;
n: string;
e: string;
}[];
}
export async function verifyToken(token: string): Promise<boolean> {
const decoded = decode(token, { complete: true });
const jwks = await fetch(JWKS)
.then(res => res.json() as Promise<JWKSResponse>);
const jwks = await fetch(JWKS).then(
(res) => res.json() as Promise<JWKSResponse>,
);
if (!decoded || !decoded.header || !decoded.header.kid) {
return false;
}
const key = jwks.keys.find(k => k.kid === decoded.header.kid);
const key = jwks.keys.find((k) => k.kid === decoded.header.kid);
if (!key) {
return false;
}
@ -24,7 +32,7 @@ export async function verifyToken(token: string): Promise<boolean> {
const res = verify(token, pem, { algorithms: [key.alg] });
console.log(res);
return typeof res === "object" && "sub" in res;
} catch (err) {
} catch (_err) {
return false;
}
}

View File

@ -1,5 +1,5 @@
import { Pool } from "pg";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL
})
connectionString: process.env.DATABASE_URL,
});

View File

@ -42,19 +42,19 @@ app.use(
app.get("/api/config", (c) => {
const capabilities: string[] = [];
if(process.env.OIDC_ENABLED) {
if (process.env.OIDC_ENABLED) {
capabilities.push("auth");
}
if(process.env.REVIEWS_ENABLED) {
if (process.env.REVIEWS_ENABLED) {
capabilities.push("reviews");
}
if(process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
capabilities.push("ai");
}
if(process.env.TANKERKOENIG_API_KEY) {
if (process.env.TANKERKOENIG_API_KEY) {
capabilities.push("fuel");
}
@ -62,17 +62,19 @@ app.get("/api/config", (c) => {
name: "TrafficCue Server",
version: "0",
capabilities,
oidc: process.env.OIDC_ENABLED ? {
AUTH_URL: process.env.OIDC_AUTH_URL,
CLIENT_ID: process.env.OIDC_CLIENT_ID,
TOKEN_URL: process.env.OIDC_TOKEN_URL
} : undefined
})
})
oidc: process.env.OIDC_ENABLED
? {
AUTH_URL: process.env.OIDC_AUTH_URL,
CLIENT_ID: process.env.OIDC_CLIENT_ID,
TOKEN_URL: process.env.OIDC_TOKEN_URL,
}
: undefined,
});
});
if(process.env.REVIEWS_ENABLED) {
if (process.env.REVIEWS_ENABLED) {
app.get("/api/reviews", async (c) => {
let {lat, lon} = c.req.query();
let { lat, lon } = c.req.query();
if (!lat || !lon) {
return c.json({ error: "Latitude and longitude are required" }, 400);
}
@ -84,22 +86,29 @@ if(process.env.REVIEWS_ENABLED) {
"SELECT * FROM reviews WHERE latitude = $1 AND longitude = $2",
[lat, lon],
);
return c.json(await Promise.all(res.rows.map(async (row) => {
return {
id: row.id,
user_id: row.user_id,
rating: row.rating,
comment: row.comment,
created_at: row.created_at,
username: "Me" // TODO: Sync OIDC users with the database
};
})));
return c.json(
await Promise.all(
res.rows.map(async (row) => {
return {
id: row.id,
user_id: row.user_id,
rating: row.rating,
comment: row.comment,
created_at: row.created_at,
username: "Me", // TODO: Sync OIDC users with the database
};
}),
),
);
});
app.post("/api/review", async (c) => {
const { rating, comment, lat, lon } = await c.req.json();
if (!rating || !lat || !lon) {
return c.json({ error: "Rating, latitude, and longitude are required" }, 400);
return c.json(
{ error: "Rating, latitude, and longitude are required" },
400,
);
}
const authHeader = c.req.header("Authorization");
@ -126,10 +135,10 @@ if(process.env.REVIEWS_ENABLED) {
);
return c.json(res.rows[0]);
})
});
}
if(process.env.TANKERKOENIG_API_KEY) {
if (process.env.TANKERKOENIG_API_KEY) {
app.get("/api/fuel/list", async (c) => {
// pass GET query parameters to the tankerkoenig API
const params = new URLSearchParams(c.req.query());
@ -168,68 +177,94 @@ if(process.env.TANKERKOENIG_API_KEY) {
});
}
if(process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
app.use("/api/ai", rateLimiter({
windowMs: 60 * 1000, // 1 minute
limit: 50, // 10 requests per minute
standardHeaders: "draft-6",
keyGenerator: (c) => "global"
}))
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
app.use(
"/api/ai",
rateLimiter({
windowMs: 60 * 1000, // 1 minute
limit: 50, // 10 requests per minute
standardHeaders: "draft-6",
keyGenerator: (_c) => "global",
}),
);
app.post("/api/ai", post);
}
let wsSubscribers: Record<string, WSContext<ServerWebSocket>[]> = {};
const wsSubscribers: Record<string, WSContext<ServerWebSocket>[]> = {};
app.get("/api/ws", upgradeWebSocket((c) => {
let advertising = "";
return {
onOpen(e, ws) {
console.log("WebSocket connection opened");
ws.send(JSON.stringify({ type: "welcome", message: "Welcome to TrafficCue WebSocket!" }));
},
onMessage(e, ws) {
const data = JSON.parse(e.data.toString());
console.log("WebSocket message received:", data);
app.get(
"/api/ws",
upgradeWebSocket((_c) => {
let advertising = "";
return {
onOpen(e, ws) {
console.log("WebSocket connection opened");
ws.send(
JSON.stringify({
type: "welcome",
message: "Welcome to TrafficCue WebSocket!",
}),
);
},
onMessage(e, ws) {
const data = JSON.parse(e.data.toString());
console.log("WebSocket message received:", data);
if (data.type === "advertise") {
const code = data.code || randomCode();
wsSubscribers[code] = wsSubscribers[code] || [];
advertising = code;
ws.send(JSON.stringify({ type: "advertising", code }));
} else if (data.type === "subscribe") {
const code = data.code;
if (!code || !wsSubscribers[code]) {
ws.send(JSON.stringify({ type: "error", message: "Invalid or unknown code" }));
return;
}
wsSubscribers[code].push(ws);
ws.send(JSON.stringify({ type: "subscribed", code }));
} else if (data.type === "location") {
const subscribers = wsSubscribers[advertising] || [];
subscribers.forEach(subscriber => {
if (subscriber !== ws) {
subscriber.send(JSON.stringify({ type: "location", location: data.location, route: data.route }));
if (data.type === "advertise") {
const code = data.code || randomCode();
wsSubscribers[code] = wsSubscribers[code] || [];
advertising = code;
ws.send(JSON.stringify({ type: "advertising", code }));
} else if (data.type === "subscribe") {
const code = data.code;
if (!code || !wsSubscribers[code]) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid or unknown code",
}),
);
return;
}
});
} else {
ws.send(JSON.stringify({ type: "error", message: "Unknown message type" }));
}
},
onClose(e, ws) {
// If they are subscribing, remove them from the subscribers list
for (const code in wsSubscribers) {
if (wsSubscribers[code]) {
wsSubscribers[code] = wsSubscribers[code].filter(subscriber => subscriber !== ws);
if (wsSubscribers[code].length === 0) {
delete wsSubscribers[code];
wsSubscribers[code].push(ws);
ws.send(JSON.stringify({ type: "subscribed", code }));
} else if (data.type === "location") {
const subscribers = wsSubscribers[advertising] || [];
subscribers.forEach((subscriber) => {
if (subscriber !== ws) {
subscriber.send(
JSON.stringify({
type: "location",
location: data.location,
route: data.route,
}),
);
}
});
} else {
ws.send(
JSON.stringify({ type: "error", message: "Unknown message type" }),
);
}
},
onClose(e, ws) {
// If they are subscribing, remove them from the subscribers list
for (const code in wsSubscribers) {
if (wsSubscribers[code]) {
wsSubscribers[code] = wsSubscribers[code].filter(
(subscriber) => subscriber !== ws,
);
if (wsSubscribers[code].length === 0) {
delete wsSubscribers[code];
}
}
}
}
}
}
}));
},
};
}),
);
function randomCode(length: number = 6): string {
function randomCode(length = 6): string {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let result = "";
for (let i = 0; i < length; i++) {
@ -240,9 +275,9 @@ function randomCode(length: number = 6): string {
app.get("/", (c) => {
return c.text("TrafficCue Server");
})
});
export default {
fetch: app.fetch,
websocket
}
websocket,
};