From d637ba8b351b4ad57ad604c44a7a304970d9aee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=81=A7?= Date: Tue, 20 Aug 2024 14:29:21 +0200 Subject: [PATCH] Add deflector --- .github/workflows/build_frontends.yml | 7 +- Tiltfile | 23 ++-- api/package.json | 1 + api/src/app.ts | 2 + api/src/index.node.ts | 6 ++ api/src/lib/deflector/index.ts | 11 ++ api/src/lib/deflector/middleware.ts | 100 ++++++++++++++++++ api/src/routes/deflector.ts | 31 ++++++ api/src/routes/settings.ts | 12 ++- biome.jsonc | 5 +- .../pages/RequestorPage/DeflectorResponse.tsx | 56 ++++++++++ .../pages/RequestorPage/RequestorHistory.tsx | 6 +- .../src/pages/RequestorPage/RequestorPage.tsx | 5 + .../src/pages/RequestorPage/ResponsePanel.tsx | 24 ++++- .../src/pages/RequestorPage/RoutesPanel.tsx | 76 +++++++++++-- frontend/src/pages/RequestorPage/queries.ts | 4 +- frontend/src/pages/RequestorPage/utils.ts | 8 ++ .../ProxyRequestsSettingsForm.tsx | 44 +++++++- frontend/src/pages/SettingsPage/form/form.tsx | 2 + frontend/src/pages/SettingsPage/form/types.ts | 1 + package.json | 1 + packages/utils/.gitignore | 1 + packages/utils/.npmignore | 38 +++++++ packages/utils/package.json | 25 +++++ packages/utils/src/index.ts | 78 ++++++++++++++ packages/utils/tsconfig.json | 16 +++ pnpm-lock.yaml | 13 +++ webhonc/package.json | 1 + webhonc/src/index.ts | 5 +- webhonc/src/utils.ts | 68 ------------ 30 files changed, 565 insertions(+), 105 deletions(-) create mode 100644 api/src/lib/deflector/index.ts create mode 100644 api/src/lib/deflector/middleware.ts create mode 100644 api/src/routes/deflector.ts create mode 100644 frontend/src/pages/RequestorPage/DeflectorResponse.tsx create mode 100644 packages/utils/.gitignore create mode 100644 packages/utils/.npmignore create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/tsconfig.json diff --git a/.github/workflows/build_frontends.yml b/.github/workflows/build_frontends.yml index f94d3cbad..dc201bb9c 100644 --- a/.github/workflows/build_frontends.yml +++ b/.github/workflows/build_frontends.yml @@ -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 diff --git a/Tiltfile b/Tiltfile index c4c75965b..3f44caec4 100644 --- a/Tiltfile +++ b/Tiltfile @@ -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 @@ -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"], ) @@ -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, ) @@ -38,7 +38,7 @@ local_resource( "db-generate", labels=["api"], dir="api", - cmd="npm run db:generate", + cmd="pnpm db:generate", deps=["api/drizzle.config.ts"], ) @@ -46,7 +46,7 @@ local_resource( "db-migrate", labels=["api"], dir="api", - cmd="npm run db:migrate", + cmd="pnpm db:migrate", deps=["api/migrate.ts"], ) @@ -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", -) \ No newline at end of file +) + +local_resource( + "reset-db", + labels=["api"], + cmd="rm fpx.db", + dir="api", + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, +) diff --git a/api/package.json b/api/package.json index 0b43f248a..141538d89 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/app.ts b/api/src/app.ts index a7f94d5a1..93316a283 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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"; @@ -58,6 +59,7 @@ export function createApp( app.route("/", source); app.route("/", appRoutes); app.route("/", settings); + app.route("/", deflector); return app; } diff --git a/api/src/index.node.ts b/api/src/index.node.ts index 4f79879fc..f74b95e94 100644 --- a/api/src/index.node.ts +++ b/api/src/index.node.ts @@ -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"; @@ -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! diff --git a/api/src/lib/deflector/index.ts b/api/src/lib/deflector/index.ts new file mode 100644 index 000000000..f2737e7cd --- /dev/null +++ b/api/src/lib/deflector/index.ts @@ -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"; diff --git a/api/src/lib/deflector/middleware.ts b/api/src/lib/deflector/middleware.ts new file mode 100644 index 000000000..57658c230 --- /dev/null +++ b/api/src/lib/deflector/middleware.ts @@ -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]; + } +} diff --git a/api/src/routes/deflector.ts b/api/src/routes/deflector.ts new file mode 100644 index 000000000..57bb4e288 --- /dev/null +++ b/api/src/routes/deflector.ts @@ -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; diff --git a/api/src/routes/settings.ts b/api/src/routes/settings.ts index 2cd4161c2..3190fcaf5 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/settings.ts @@ -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"; @@ -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); }); diff --git a/biome.jsonc b/biome.jsonc index 5ecac3e52..79a6050a5 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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" ] } } diff --git a/frontend/src/pages/RequestorPage/DeflectorResponse.tsx b/frontend/src/pages/RequestorPage/DeflectorResponse.tsx new file mode 100644 index 000000000..e7132b643 --- /dev/null +++ b/frontend/src/pages/RequestorPage/DeflectorResponse.tsx @@ -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 ( +
+
+ +
+
+ setText(value ?? "")} + /> +
+
+ ); +} diff --git a/frontend/src/pages/RequestorPage/RequestorHistory.tsx b/frontend/src/pages/RequestorPage/RequestorHistory.tsx index 00b0f19c4..4ea055a20 100644 --- a/frontend/src/pages/RequestorPage/RequestorHistory.tsx +++ b/frontend/src/pages/RequestorPage/RequestorHistory.tsx @@ -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; @@ -86,7 +87,9 @@ export function HistoryEntry({
@@ -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, )} > diff --git a/frontend/src/pages/RequestorPage/RequestorPage.tsx b/frontend/src/pages/RequestorPage/RequestorPage.tsx index e45974a51..49edc81bd 100644 --- a/frontend/src/pages/RequestorPage/RequestorPage.tsx +++ b/frontend/src/pages/RequestorPage/RequestorPage.tsx @@ -182,6 +182,10 @@ export const RequestorPage = () => { body, ); + const deflectorTarget = requestHeaders.find( + (h) => h.key === "x-fpx-deflect-to", + )?.value; + useHotkeys( "mod+g", (e) => { @@ -334,6 +338,7 @@ export const RequestorPage = () => { requestType={selectedRoute?.requestType} openAiTestGenerationPanel={toggleAiTestGenerationPanel} isAiTestGenerationPanelOpen={isAiTestGenerationPanelOpen} + deflectorTarget={deflectorTarget} /> {isAiTestGenerationPanelOpen && ( void; isAiTestGenerationPanelOpen: boolean; + deflectorTarget?: string; }; export function ResponsePanel({ @@ -70,6 +72,7 @@ export function ResponsePanel({ websocketState, openAiTestGenerationPanel, isAiTestGenerationPanelOpen, + deflectorTarget, }: Props) { // NOTE - If we have a "raw" response, we want to render that, so we can (e.g.,) show binary data const responseToRender = activeResponse ?? tracedResponse; @@ -155,7 +158,9 @@ export function ResponsePanel({ ) } EmptyState={ - isWsRequest(requestType) ? ( + !responseToRender && deflectorTarget ? ( + + ) : isWsRequest(requestType) ? ( ) : ( @@ -255,7 +260,10 @@ function CollapsibleBodyContainer({ const BottomToolbar = ({ response, disableGoToTraceButton, -}: { response?: Requestornator | null; disableGoToTraceButton: boolean }) => { +}: { + response?: Requestornator | null; + disableGoToTraceButton: boolean; +}) => { const traceId = response?.app_responses?.traceId; return ( @@ -284,7 +292,9 @@ const BottomToolbar = ({ function WebsocketMessages({ websocketState, -}: { websocketState: WebSocketState }) { +}: { + websocketState: WebSocketState; +}) { return (
Messages
@@ -354,7 +364,9 @@ function TabContentInner({ function ResponseSummary({ response, -}: { response?: Requestornator | RequestorActiveResponse }) { +}: { + response?: Requestornator | RequestorActiveResponse; +}) { const status = isRequestorActiveResponse(response) ? response?.responseStatusCode : response?.app_responses?.responseStatusCode; @@ -707,7 +719,9 @@ function LoadingResponseBody() { function FailedRequest({ response, -}: { response?: Requestornator | RequestorActiveResponse }) { +}: { + response?: Requestornator | RequestorActiveResponse; +}) { // TODO - Show a more friendly error message const failureReason = isRequestorActiveResponse(response) ? null diff --git a/frontend/src/pages/RequestorPage/RoutesPanel.tsx b/frontend/src/pages/RequestorPage/RoutesPanel.tsx index a399d6d42..cc962879d 100644 --- a/frontend/src/pages/RequestorPage/RoutesPanel.tsx +++ b/frontend/src/pages/RequestorPage/RoutesPanel.tsx @@ -2,12 +2,13 @@ import { Input } from "@/components/ui/input"; import { useCustomRoutesEnabled } from "@/hooks"; import { cn } from "@/utils"; import { + AngleIcon, CaretDownIcon, CaretRightIcon, ClockIcon, TrashIcon, } from "@radix-ui/react-icons"; -import { useMemo, useState } from "react"; +import { ReactNode, useMemo, useState } from "react"; import { Resizable } from "react-resizable"; import { RequestorHistory } from "./RequestorHistory"; import { ResizableHandle } from "./Resizable"; @@ -101,15 +102,45 @@ export function RoutesPanel({ return history.length > 0; }, [history]); + const [requests, deflections] = useMemo(() => { + return history.reduce<[Requestornator[], Requestornator[]]>( + ([requests, deflections], r) => { + if (r.app_requests?.requestHeaders?.["x-fpx-deflect-to"]) { + deflections.push(r); + } else { + requests.push(r); + } + return [requests, deflections]; + }, + [[], []], + ); + }, [history]); + + const deflectionTargets = useMemo(() => { + return deflections.reduce<{ [target: string]: Array }>( + (acc, r) => { + const target = r.app_requests?.requestHeaders?.["x-fpx-deflect-to"]; + if (target) { + if (!Object.prototype.hasOwnProperty.call(acc, target)) { + acc[target] = []; + } + acc[target].push(r); + } + return acc; + }, + {}, + ); + }, [deflections]); + const filteredHistory = useMemo(() => { const cleanFilter = filterValue.trim().toLowerCase(); - if (cleanFilter.length < 3 && history) { - return history; + if (cleanFilter.length < 3 && requests) { + return requests; } - return history?.filter((r) => - r.app_requests?.requestUrl?.includes(filterValue), - ); - }, [filterValue, history]); + return requests?.filter((r) => { + return r.app_requests?.requestUrl?.includes(filterValue); + }); + }, [filterValue, requests]); return ( )} + {Object.keys(deflectionTargets).map((target) => ( + + + {target} + + } + loadHistoricalRequest={loadHistoricalRequest} + /> + ))} + {hasAnyDraftRoutes && ( ; loadHistoricalRequest: (traceId: string) => void; + title?: string | ReactNode; }; function HistorySection({ history, loadHistoricalRequest, + title = ( + <> + + History + + ), }: HistorySectionProps) { const [showHistorySection, setShowHistorySection] = useState(false); const ShowHistorySectionIcon = showHistorySection @@ -236,8 +288,7 @@ function HistorySection({ > - - History + {title}
@@ -252,7 +303,7 @@ function HistorySection({ } type RoutesSectionProps = { - title: string; + title: string | ReactNode; routes: ProbedRoute[]; selectedRoute: ProbedRoute | null; handleRouteClick: (route: ProbedRoute) => void; @@ -305,7 +356,10 @@ function RoutesSection(props: RoutesSectionProps) { export function RouteItem({ route, deleteDraftRoute, -}: { route: ProbedRoute; deleteDraftRoute?: () => void }) { +}: { + route: ProbedRoute; + deleteDraftRoute?: () => void; +}) { const { mutate: deleteRoute } = useDeleteRoute(); const canDeleteRoute = route.routeOrigin === "custom" || diff --git a/frontend/src/pages/RequestorPage/queries.ts b/frontend/src/pages/RequestorPage/queries.ts index 22ef583d2..cab4d849e 100644 --- a/frontend/src/pages/RequestorPage/queries.ts +++ b/frontend/src/pages/RequestorPage/queries.ts @@ -65,7 +65,7 @@ export type Requestornator = { }; }; -const REQUESTOR_REQUESTS_KEY = "requestorRequests"; +export const REQUESTOR_REQUESTS_KEY = "requestorRequests"; type ProbedRoutesResponse = { baseUrl: string; @@ -142,7 +142,7 @@ export function useDeleteRoute() { } export function useFetchRequestorRequests() { - return useQuery({ + return useQuery>({ queryKey: [REQUESTOR_REQUESTS_KEY], queryFn: () => fetch("/v0/all-requests").then((r) => r.json()), }); diff --git a/frontend/src/pages/RequestorPage/utils.ts b/frontend/src/pages/RequestorPage/utils.ts index 7ab35fc35..437450cf1 100644 --- a/frontend/src/pages/RequestorPage/utils.ts +++ b/frontend/src/pages/RequestorPage/utils.ts @@ -14,3 +14,11 @@ export function sortRequestornatorsDescending( } return 0; } + +export function hasDeflectionOngoing(response?: Requestornator) { + const hasResponse = response?.app_responses !== null; + const hasDeflection = + response?.app_requests?.requestHeaders?.["x-fpx-deflect-to"]; + + return !hasResponse && hasDeflection; +} diff --git a/frontend/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx b/frontend/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx index cd20b2589..1dc969a7a 100644 --- a/frontend/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx +++ b/frontend/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx @@ -12,10 +12,11 @@ import { Switch } from "@/components/ui/switch"; import { cn } from "@/utils"; import { useSettingsForm } from "./form"; -// TODO: automatically restart the fpx studio when this is changed export function ProxyRequestsSettingsForm({ settings, -}: { settings: Record }) { +}: { + settings: Record; +}) { const { form, onSubmit } = useSettingsForm(settings); const isProxyRequestsDirty = form.formState.dirtyFields.proxyRequestsEnabled; @@ -23,7 +24,7 @@ export function ProxyRequestsSettingsForm({
-

Public URL Settings

+

Proxy Settings

+
+ ( + +
+
+ + Enable proxy from this FPX + + (Alpha) + + + + Enable proxying of requests from the FPX running on your + device. Just add a x-fpx-deflect-to header to your + requests and point them to {window.location.protocol}// + {window.location.hostname}:{window.location.port}, they + will automatically appear in FPX. + +
+ + + +
+
+ )} + /> +