diff --git a/.github/workflows/build_frontends.yml b/.github/workflows/build_frontends.yml index 2a2501429..fd17226a5 100644 --- a/.github/workflows/build_frontends.yml +++ b/.github/workflows/build_frontends.yml @@ -41,8 +41,10 @@ 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 306fc0427..2d37ac609 100644 --- a/Tiltfile +++ b/Tiltfile @@ -1,10 +1,10 @@ -# Automagically install & update npm dependencies when package.json changes +# Automagically install & update pnpm dependencies when package.json changes local_resource( "node_modules", - labels=["api", "frontend"], - deps=["package.json", "api/package.json", "frontend/package.json"], + labels=["api", "studio"], + deps=["package.json", "api/package.json", "studio/package.json"], dir=".", - cmd="npm install", + cmd="pnpm install", ) # Ensure the api/dist directory exists @@ -14,22 +14,31 @@ local_resource( cmd="mkdir api/dist || true", ) -# Build & serve the frontend local_resource( - "frontend-build", - labels=["frontend"], - cmd="npm run clean:frontend && npm run build:frontend", - deps=["frontend/src"], + "packages-build", + labels=["studio"], + cmd="pnpm --filter @fiberplane/fpx-types build && pnpm --filter @fiberplane/fpx-utils build && pnpm --filter @fiberplane/hono-otel build", + deps=["packages"], + ignore=["packages/*/dist"], +) + +# Build & serve the studio +local_resource( + "studio-build", + labels=["studio"], + cmd="pnpm clean:frontend && pnpm build:frontend", + deps=["studio/src"], resource_deps=["node_modules", "api-dist"], ) local_resource( - "frontend-serve", - labels=["frontend"], + "studio-serve", + labels=["studio"], deps=["studio/src"], resource_deps=["node_modules", "api-dist"], - serve_cmd="npm run dev", + serve_cmd="pnpm dev", serve_dir="studio", + auto_init=False, trigger_mode=TRIGGER_MODE_MANUAL, ) @@ -38,7 +47,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 +55,7 @@ local_resource( "db-migrate", labels=["api"], dir="api", - cmd="npm run db:migrate", + cmd="pnpm db:migrate", deps=["api/migrate.ts"], ) @@ -55,6 +64,36 @@ 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, +) + +local_resource( + "format", + labels=["api", "studio"], + cmd="pnpm format", + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, +) + + +# Examples + +local_resource( + "examples-node-api", + dir="examples/node-api", + labels=["examples"], + serve_dir="examples/node-api", + serve_cmd="pnpm dev", + auto_init=False, + trigger_mode=TRIGGER_MODE_MANUAL, +) \ No newline at end of file diff --git a/api/package.json b/api/package.json index aa521d34b..c42a90c36 100644 --- a/api/package.json +++ b/api/package.json @@ -44,6 +44,8 @@ "@iarna/toml": "^2.2.5", "@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 dae2063e1..89aecd6e7 100644 --- a/api/src/index.node.ts +++ b/api/src/index.node.ts @@ -9,6 +9,10 @@ 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, + setDeflectorStatus, +} 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"; @@ -33,6 +37,11 @@ const db = drizzle(sql, { schema }); // Set up the api routes const app = createApp(db, webhonc, wsConnections); +/** + * Deflector middleware has to go before the frontend routes handler to work + */ +app.use(deflectorMiddleware); + /** * Serve all the frontend static files */ @@ -92,3 +101,8 @@ if (proxyRequestsEnabled ?? false) { logger.debug("Proxy requests feature enabled."); await webhonc.start(); } + +// check settings if proxy deflector is enabled +const proxyDeflectorEnabled = await getSetting(db, "proxyDeflectorEnabled"); + +setDeflectorStatus(proxyDeflectorEnabled ?? false); 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..bee0b9291 --- /dev/null +++ b/api/src/lib/deflector/middleware.ts @@ -0,0 +1,130 @@ +import { headersToObject, resolveBody } from "@fiberplane/fpx-utils"; +import type { MiddlewareHandler } from "hono"; + +import { eq } from "drizzle-orm"; +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, deflectionType] = getTargetUrlAndDeflectionType( + 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 (deflectionType === "proxy") { + response = await fetch(requestUrl, { + method: c.req.method, + headers: newHeaders, + body: c.req.raw.body, + }); + } else if (deflectionType === "serverSimulator") { + response = await new Promise((resolve, reject) => { + parkingLot.set(traceId, [c, resolve, reject]); + }); + } else if (deflectionType === "mock") { + const [r1] = await db + .select() + .from(schema.appRequests) + .then((requests) => + requests.filter((request) => { + return request.requestHeaders?.["x-fpx-deflect-to"] !== undefined; + }), + ); + + if (r1?.id) { + const [matchingResponse] = await db + .select() + .from(schema.appResponses) + .where(eq(schema.appResponses.requestId, r1.id)); + response = new Response(matchingResponse.responseBody, { + status: matchingResponse.responseStatusCode ?? 200, + headers: matchingResponse.responseHeaders ?? {}, + }); + } else { + throw new Error(); + } + } else { + throw new Error(); + } + 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); + } +}; + +type DeflectionType = "proxy" | "serverSimulator" | "mock"; + +function getTargetUrlAndDeflectionType( + targetString: string, + requestString: string, +): [finalUrl: URL, deflectionType: DeflectionType] { + 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, "proxy"]; + } catch { + const url = new URL(requestString); + url.hostname = targetString; + return [url, "serverSimulator"]; + } +} 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 177c89c24..8971d0460 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/settings.ts @@ -1,6 +1,7 @@ import { SettingsSchema } from "@fiberplane/fpx-types"; 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"; @@ -53,6 +54,12 @@ app.post("/v0/settings", cors(), async (ctx) => { await webhonc.stop(); } + const proxyDeflectorEnabled = + updatedSettings.find((setting) => setting.key === "proxyDeflectorEnabled") + ?.value === "true" ?? false; + + setDeflectorStatus(proxyDeflectorEnabled); + return ctx.json(updatedSettings); }); diff --git a/biome.jsonc b/biome.jsonc index 361172bf1..803260de7 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -95,13 +95,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/package.json b/package.json index fe2d9ab17..c0f42e99e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "preinstall": "npx only-allow pnpm", "build:types": "pnpm --filter @fiberplane/fpx-types build", + "build:utils": "pnpm --filter @fiberplane/fpx-utils build", "build:fpx-studio": "pnpm run clean:fpx-studio && pnpm run build:api && pnpm run build:frontend", "build:api": "pnpm --filter @fiberplane/studio build", "build:www": "pnpm --filter www build", @@ -29,6 +30,7 @@ "homepage": "https://github.com/fiberplane/fpx#readme", "devDependencies": { "@biomejs/biome": "^1.8.3", + "dotenv": "^16.4.5", "pkg-pr-new": "^0.0.20", "rimraf": "^5.0.7", "typescript": "^5.5.4", diff --git a/packages/types/src/settings.ts b/packages/types/src/settings.ts index d3d089c28..1651d7582 100644 --- a/packages/types/src/settings.ts +++ b/packages/types/src/settings.ts @@ -70,6 +70,7 @@ export const SettingsSchema = z.object({ openaiBaseUrl: z.string().optional(), openaiModel: OpenAiModelSchema.optional(), proxyBaseUrl: z.string().optional(), + proxyDeflectorEnabled: z.boolean().optional(), proxyRequestsEnabled: z.boolean().optional(), webhoncConnectionId: z.string().optional(), }); diff --git a/packages/utils/.gitignore b/packages/utils/.gitignore new file mode 100644 index 000000000..53c37a166 --- /dev/null +++ b/packages/utils/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/packages/utils/.npmignore b/packages/utils/.npmignore new file mode 100644 index 000000000..1edda7b46 --- /dev/null +++ b/packages/utils/.npmignore @@ -0,0 +1,28 @@ +node_modules +# do not publish biome config +biome.jsonc + +# Change them to your taste: +package-lock.json +yarn.lock +pnpm-lock.yaml +bun.lockb + +# mac +.DS_Store + +# VS Code +.vscode/* +*.code-workspace + +# CLion +.idea + +# TypeScript / Yarn +node_modules +package-lock.json + +# Build tools +*.tsbuildinfo + +.git/* \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 000000000..a419ce25d --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,25 @@ +{ + "name": "@fiberplane/fpx-utils", + "description": "Shared utils for fpx", + "version": "0.0.4", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT or Apache 2", + "scripts": { + "clean": "rm -rf dist", + "build": "pnpm clean && tsc" + }, + "dependencies": { + "hono": "^4.4.11" + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..266d5164f --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,83 @@ +import type { HonoRequest } from "hono"; + +export function headersToObject(headers: Headers) { + const returnObject: Record = {}; + headers.forEach((value, key) => { + returnObject[key] = value; + }); + + return returnObject; +} + +export async function resolveBody< + Request extends Pick< + HonoRequest, + "header" | "method" | "formData" | "json" | "text" + >, +>(request: Request) { + 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()); + } + + // 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; + } +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..889d7c7bf --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "declaration": true, + "declarationMap": true, + "inlineSourceMap": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] + } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29a996f9f..bb6da4421 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@biomejs/biome': specifier: ^1.8.3 version: 1.8.3 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 pkg-pr-new: specifier: ^0.0.20 version: 0.0.20 @@ -32,6 +35,9 @@ importers: '@fiberplane/fpx-types': specifier: workspace:* version: link:../packages/types + '@fiberplane/fpx-utils': + specifier: workspace:* + version: link:../packages/utils '@hono/node-server': specifier: ^1.11.1 version: 1.12.0 @@ -267,6 +273,12 @@ importers: specifier: ^3.23.8 version: 3.23.8 + packages/utils: + dependencies: + hono: + specifier: ^4.4.11 + version: 4.5.9 + studio: dependencies: '@codemirror/lang-javascript': @@ -486,6 +498,9 @@ importers: '@fiberplane/fpx-types': specifier: workspace:* version: link:../packages/types + '@fiberplane/fpx-utils': + specifier: workspace:* + version: link:../packages/utils '@hono/zod-validator': specifier: ^0.2.2 version: 0.2.2(hono@4.5.5)(zod@3.23.8) @@ -5069,7 +5084,6 @@ packages: libsql@0.3.19: resolution: {integrity: sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA==} - cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lilconfig@2.1.0: diff --git a/studio/src/components/ui/status.tsx b/studio/src/components/ui/status.tsx index 39815b33b..2d74d22e2 100644 --- a/studio/src/components/ui/status.tsx +++ b/studio/src/components/ui/status.tsx @@ -4,7 +4,10 @@ import clsx from "clsx"; export function Status({ className, statusCode, -}: { className?: string; statusCode: number | undefined }) { +}: { + className?: string; + statusCode: number | undefined; +}) { if (!statusCode) { return null; } diff --git a/studio/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx b/studio/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx index e74c2d2b1..d645c5ad4 100644 --- a/studio/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx +++ b/studio/src/pages/SettingsPage/ProxyRequestsSettingsForm.tsx @@ -13,12 +13,15 @@ import { cn } from "@/utils"; import type { Settings } from "@fiberplane/fpx-types"; import { useSettingsForm } from "./form"; -// TODO: automatically restart the fpx studio when this is changed export function ProxyRequestsSettingsForm({ settings, -}: { settings: Settings }) { +}: { + settings: Settings; +}) { const { form, onSubmit } = useSettingsForm(settings); const isProxyRequestsDirty = form.formState.dirtyFields.proxyRequestsEnabled; + const isProxyDeflectorDirty = + form.formState.dirtyFields.proxyDeflectorEnabled; return (
@@ -105,6 +108,58 @@ export function ProxyRequestsSettingsForm({ )} /> +
+ ( + +
+
+ + Enable proxy from this FPX + + (Alpha) + + + +

+ Enable proxying of requests from FPX running on your + device. This feature is useful if you want to use FPX + with frameworks/languages that aren't currently + supported by it. +

+

+ To make use of this feature, configure your frontend + to send requests to the FPX instance( + {`${window.location.protocol}// + ${window.location.hostname}:${window.location.port}`} + ) instead of your backend. Be sure to also add a{" "} + + x-fpx-deflect-to + {" "} + header to your requests with the URL of the backend to + make sure FPX knows where to forward the request. All + requests passing through FPX will be recorded and + inspectable in the UI. +

+
+
+ + + +
+
+ )} + /> +