Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add deflector #128

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/build_frontends.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 54 additions & 15 deletions Tiltfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
)

Expand All @@ -38,15 +47,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 +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,
)
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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;
}
14 changes: 14 additions & 0 deletions api/src/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
*/
Expand Down Expand Up @@ -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);
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";
130 changes: 130 additions & 0 deletions api/src/lib/deflector/middleware.ts
Original file line number Diff line number Diff line change
@@ -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"];
}
}
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;
7 changes: 7 additions & 0 deletions api/src/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});

Expand Down
5 changes: 4 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
Loading
Loading