Skip to content

Commit

Permalink
Add deflector
Browse files Browse the repository at this point in the history
  • Loading branch information
actualwitch committed Aug 21, 2024
1 parent e1b8a4b commit d637ba8
Show file tree
Hide file tree
Showing 30 changed files with 565 additions and 105 deletions.
7 changes: 5 additions & 2 deletions .github/workflows/build_frontends.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ jobs:
env:
CI: true

- name: Build shared types
run: pnpm build:types
- name: Build shared types & utils
run: |
pnpm build:types
pnpm build:utils
# Linting: we use global biome command
# any extra commands should be added to the lint:ci script
Expand Down
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,
)
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@langchain/core": "^0.2.15",
"@libsql/client": "^0.6.2",
"@fiberplane/fpx-types": "workspace:*",
"@fiberplane/fpx-utils": "workspace:*",
"acorn": "^8.11.3",
"acorn-walk": "^8.3.2",
"chalk": "^5.3.0",
Expand Down
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;
}
6 changes: 6 additions & 0 deletions api/src/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { WebSocket } from "ws";
import { createApp } from "./app.js";
import { DEFAULT_DATABASE_URL } from "./constants.js";
import * as schema from "./db/schema.js";
import { deflectorMiddleware } from "./lib/deflector/middleware.js";
import { setupRealtimeService } from "./lib/realtime/index.js";
import { getSetting } from "./lib/settings/index.js";
import { resolveWebhoncUrl } from "./lib/utils.js";
Expand Down Expand Up @@ -38,6 +39,11 @@ 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";
100 changes: 100 additions & 0 deletions api/src/lib/deflector/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { headersToObject, resolveBody } from "@fiberplane/fpx-utils";
import type { 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 schema.NewAppRequest["requestMethod"],
requestUrl: requestUrl.toString(),
requestHeaders: headersToObject(newHeaders),
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];
}
}
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 { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { parkingLot } from "../lib/deflector/index.js";
import type { Bindings, Variables } from "../lib/types.js";

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;
12 changes: 9 additions & 3 deletions api/src/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { setDeflectorStatus } from "../lib/deflector/middleware.js";
import { getAllSettings, upsertSettings } from "../lib/settings/index.js";
import type { Bindings, Variables } from "../lib/types.js";
import logger from "../logger.js";
Expand Down Expand Up @@ -37,12 +38,17 @@ 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
5 changes: 4 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,16 @@
".astro",

// ignore all tsconfig.json files
"tsconfig.json"
"tsconfig.json",

// Rust code related
// This caused biome to ignore the entire fpx folder
// commenting out for now as we still want to find a way to
// skip Rust code in biome
// "fpx/*.*"

// python venv
".venv"
]
}
}
56 changes: 56 additions & 0 deletions frontend/src/pages/RequestorPage/DeflectorResponse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/utils";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { CodeMirrorJsonEditor } from "./Editors";
import { REQUESTOR_REQUESTS_KEY } from "./queries";

export function DeflectorResponse({
deflectorTarget,
}: {
deflectorTarget: string;
}) {
const [text, setText] = useState("");
const { toast } = useToast();
const client = useQueryClient();
return (
<div className={cn("h-full grid grid-rows-[auto_1fr]")}>
<div>
<Button
onClick={async () => {
try {
// this is just to check if the JSON is valid and fail otherwise
JSON.parse(text);
setText("");
await fetch("/v0/deflector", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: deflectorTarget,
value: text,
}),
});
client.invalidateQueries({ queryKey: [REQUESTOR_REQUESTS_KEY] });
} catch (e) {
toast({
title: "Error",
description: `Invalid JSON: ${e}`,
});
}
}}
>
Send response
</Button>
</div>
<div className={cn("overflow-hidden overflow-y-auto w-full")}>
<CodeMirrorJsonEditor
value={text}
onChange={(value) => setText(value ?? "")}
/>
</div>
</div>
);
}
6 changes: 5 additions & 1 deletion frontend/src/pages/RequestorPage/RequestorHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cn, parsePathFromRequestUrl, truncatePathWithEllipsis } from "@/utils";
import { getHttpMethodTextColor } from "./method";
import { Requestornator } from "./queries";
import { hasDeflectionOngoing } from "./utils";

type RequestorHistoryProps = {
history: Array<Requestornator>;
Expand Down Expand Up @@ -86,7 +87,9 @@ export function HistoryEntry({
</div>
<div className="flex items-center ml-auto">
<StatusCode
status={responseStatusCode}
status={
hasDeflectionOngoing(response) ? "DEFL" : responseStatusCode
}
isFailure={isFailure}
className="text-xs"
/>
Expand Down Expand Up @@ -137,6 +140,7 @@ export function StatusCode({
isGreen && ["text-green-400", "bg-green-800"],
isOrange && ["text-orange-400", "bg-orange-800"],
(isRed || isFailure) && ["text-red-400", "bg-red-800"],
strStatus === "DEFL" && ["text-blue-400", "bg-blue-800"],
className,
)}
>
Expand Down
Loading

0 comments on commit d637ba8

Please sign in to comment.