Skip to content

Commit

Permalink
Add deflector
Browse files Browse the repository at this point in the history
  • Loading branch information
actualwitch committed Aug 20, 2024
1 parent 66b1baa commit c8a4f5b
Show file tree
Hide file tree
Showing 21 changed files with 501 additions and 40 deletions.
23 changes: 16 additions & 7 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ local_resource(
labels=["api", "frontend"],
deps=["package.json", "api/package.json", "frontend/package.json"],
dir=".",
cmd="npm install",
cmd="pnpm install",
)

# Ensure the api/dist directory exists
Expand All @@ -18,7 +18,7 @@ local_resource(
local_resource(
"frontend-build",
labels=["frontend"],
cmd="npm run clean:frontend && npm run build:frontend",
cmd="pnpm clean:frontend && pnpm build:frontend",
deps=["frontend/src"],
resource_deps=["node_modules", "api-dist"],
)
Expand All @@ -28,7 +28,7 @@ local_resource(
labels=["frontend"],
deps=["frontend/src"],
resource_deps=["node_modules", "api-dist"],
serve_cmd="npm run dev",
serve_cmd="pnpm dev",
serve_dir="frontend",
trigger_mode=TRIGGER_MODE_MANUAL,
)
Expand All @@ -38,15 +38,15 @@ local_resource(
"db-generate",
labels=["api"],
dir="api",
cmd="npm run db:generate",
cmd="pnpm db:generate",
deps=["api/drizzle.config.ts"],
)

local_resource(
"db-migrate",
labels=["api"],
dir="api",
cmd="npm run db:migrate",
cmd="pnpm db:migrate",
deps=["api/migrate.ts"],
)

Expand All @@ -55,6 +55,15 @@ local_resource(
"api",
labels=["api"],
resource_deps=["node_modules", "db-generate", "db-migrate"],
serve_cmd="npm run dev",
serve_cmd="pnpm dev",
serve_dir="api",
)
)

local_resource(
"reset-db",
labels=["api"],
cmd="rm fpx.db",
dir="api",
auto_init=False,
trigger_mode=TRIGGER_MODE_MANUAL,
)
2 changes: 2 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import logger from "./logger.js";

import type * as webhoncType from "./lib/webhonc/index.js";
import appRoutes from "./routes/app-routes.js";
import deflector from "./routes/deflector.js";
import inference from "./routes/inference.js";
import settings from "./routes/settings.js";
import source from "./routes/source.js";
Expand Down Expand Up @@ -58,6 +59,7 @@ export function createApp(
app.route("/", source);
app.route("/", appRoutes);
app.route("/", settings);
app.route("/", deflector);

return app;
}
7 changes: 7 additions & 0 deletions api/src/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
frontendRoutesHandler,
staticServerMiddleware,
} from "./serve-frontend-build.js";
import { deflectorMiddleware } from "./lib/deflector/middleware.js";

config({ path: ".dev.vars" });

Expand All @@ -38,6 +39,12 @@ const app = createApp(db, webhonc, wsConnections);
*/
app.use("/*", staticServerMiddleware);


/**
* Deflector middleware has to go before the frontend routes handler to work
*/
app.use(deflectorMiddleware);

/**
* Fallback route that just serves the frontend index.html file,
* This is necessary to support frontend routing!
Expand Down
11 changes: 11 additions & 0 deletions api/src/lib/deflector/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Context } from "hono";

// inversion of control container to store parked requests
export type ParkingLot = Map<
string,
[Context, (value: Response) => void, (reason: unknown) => void]
>;

export const parkingLot: ParkingLot = new Map();

export { deflectorMiddleware } from "./middleware.js";
163 changes: 163 additions & 0 deletions api/src/lib/deflector/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { HonoRequest, MiddlewareHandler } from "hono";

import * as schema from "../../db/schema.js";
import logger from "../../logger.js";
import {
handleFailedRequest,
handleSuccessfulRequest,
} from "../proxy-request/index.js";
import type { Bindings, Variables } from "../types.js";
import { parkingLot } from "./index.js";

let isDeflectorEnabled = false;
export const setDeflectorStatus = (status: boolean) => {
isDeflectorEnabled = status;
}
export const deflectorMiddleware: MiddlewareHandler<{
Bindings: Bindings;
Variables: Variables;
}> = async (c, next) => {
const deflectTo = c.req.header("x-fpx-deflect-to");
if (!isDeflectorEnabled || !deflectTo) {
return next();
}
const db = c.get("db");
const traceId = crypto.randomUUID();
const [requestUrl, isInternal] = processTarget(deflectTo, c.req.url);
logger.info(`Deflecting request to ${requestUrl}`);
const newHeaders = new Headers(c.req.raw.headers);
newHeaders.append("x-fpx-trace-id", traceId);
const [{ id: requestId }] = await db
.insert(schema.appRequests)
.values({
requestMethod: c.req.method as any,
requestUrl: requestUrl.toString(),
requestHeaders: Object.fromEntries((newHeaders as any).entries()),
requestPathParams: {},
requestQueryParams: Object.fromEntries(requestUrl.searchParams),
requestBody: await resolveBody(c.req),
requestRoute: requestUrl.pathname,
})
.returning({ id: schema.appRequests.id });
const startTime = Date.now();
newHeaders.delete("x-fpx-deflect-to");
try {
let response: Response;
if (isInternal) {
response = await new Promise((resolve, reject) => {
parkingLot.set(traceId, [c, resolve, reject]);
});
} else {
response = await fetch(requestUrl, {
method: c.req.method,
headers: newHeaders,
body: c.req.raw.body,
});
}
const duration = Date.now() - startTime;
await handleSuccessfulRequest(
db,
requestId,
duration,
response.clone(),
traceId,
);

return response;
} catch (error) {
logger.error("Error making request", error);
const duration = Date.now() - startTime;
await handleFailedRequest(db, requestId, traceId, duration, error);

return c.json({ error: "Internal server error" }, 500);
}
};

function processTarget(
targetString: string,
requestString: string,
): [URL, boolean] {
try {
const [targetUrl, requestUrl] = [targetString, requestString].map(
(url) => new URL(url),
);
for (const prop of ["hostname", "port", "protocol"] as const) {
requestUrl[prop] = targetUrl[prop];
}
return [requestUrl, false];
} catch {
const url = new URL(requestString);
url.hostname = targetString;
return [url, true];
}
}

async function resolveBody(request: HonoRequest) {
const contentType = request.header("content-type")?.toLowerCase();
const method = request.method.toUpperCase();

// Handle methods without body
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
console.debug("Method is GET, HEAD, or OPTIONS, returning null");
return null;
}

// Handle empty body
if (!contentType || request.header("content-length") === "0") {
return null;
}

try {
// JSON
if (contentType.includes("application/json")) {
console.debug("Content type is application/json, returning JSON");
return await request.json();
}

// Form data (URL-encoded)
if (contentType.includes("application/x-www-form-urlencoded")) {
console.debug(
"Content type is application/x-www-form-urlencoded, returning FormData",
);
return Object.fromEntries(
(await request.formData()) as unknown as Iterable<[string, string]>,
);
}

// TODO: Handle multipart/form-data (can contain both text and binary data)

// Plain text
if (contentType.includes("text/plain")) {
console.debug("Content type is text/plain, returning text");
return await request.text();
}

// Handle XML, HTML, JavaScript, CSS, and CSV files and other
// formats that for some reason you'd send to your API
if (
contentType.includes("application/xml") ||
contentType.includes("text/xml") ||
contentType.includes("text/html") ||
contentType === "application/javascript" ||
contentType === "text/javascript" ||
contentType === "text/css" ||
contentType === "text/csv"
) {
console.debug(
"Content type is XML, HTML, JavaScript, CSS, or CSV, returning text",
);
return await request.text();
}

// TODO: Handle binary data (application/octet-stream)
// TODO: Handle image files
// TODO: Handle PDF files

console.debug("Content type is not recognized, returning text");
// Default case: try to parse as text
return await request.text();
} catch (error) {
console.error("Error parsing request body:", error);
return null;
}
}
31 changes: 31 additions & 0 deletions api/src/routes/deflector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Hono } from "hono";
import { z } from "zod";
import type { Bindings, Variables } from "../lib/types.js";
import { parkingLot } from "../lib/deflector/index.js";
import { zValidator } from "@hono/zod-validator";

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();

app.post(
"/v0/deflector",
zValidator(
"json",
z.object({
key: z.string(),
value: z.string(),
}),
),
async (ctx) => {
const { key, value } = ctx.req.valid("json");
const fromCache = parkingLot.get(key);
if (fromCache) {
parkingLot.delete(key);
const [parkedContext, resolve] = fromCache;
resolve(parkedContext.json(JSON.parse(value)));
return ctx.json({ result: "success" });
}
return ctx.json({ error: `Unknown key: ${key}` }, 404);
},
);

export default app;
37 changes: 37 additions & 0 deletions api/src/routes/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,43 @@ app.post("/v0/generate-request", cors(), async (ctx) => {
});
});

app.post("/v0/generate-response", cors(), async (ctx) => {
const { method, url } =
await ctx.req.json();

const db = ctx.get("db");
const inferenceConfig = await getInferenceConfig(db);

if (!inferenceConfig) {
return ctx.json(
{
message: "No inference configuration found",
},
403,
);
}

const openaiClient = new OpenAI({ apiKey: inferenceConfig.openaiApiKey, baseURL: inferenceConfig.openaiBaseUrl });

const response = await openaiClient.chat.completions.create({
messages: [
{
role: "system",
content:
"You are a web server and you respond to incoming request with JSON response body.",
},
{
role: "user",
content: `${method} ${url}`,
},
],
temperature: 0.12,
model: "",
});

return ctx.json(response);
});

app.post(
"/v0/analyze-error",
cors(),
Expand Down
12 changes: 9 additions & 3 deletions api/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cors } from "hono/cors";
import { getAllSettings, upsertSettings } from "../lib/settings/index.js";
import type { Bindings, Variables } from "../lib/types.js";
import logger from "../logger.js";
import { setDeflectorStatus } from "../lib/deflector/middleware.js";

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();

Expand Down Expand Up @@ -37,11 +38,16 @@ app.post("/v0/settings", cors(), async (ctx) => {

if (proxyUrlEnabled) {
await webhonc.start();
}

if (!proxyUrlEnabled) {
} else {
await webhonc.stop();
}

const proxyDeflectorEnabled = !!Number(
updatedSettings.find((setting) => setting.key === "proxyDeflectorEnabled")
?.value,
);

setDeflectorStatus(proxyDeflectorEnabled);

return ctx.json(updatedSettings);
});
Expand Down
Loading

0 comments on commit c8a4f5b

Please sign in to comment.