Skip to content

Commit

Permalink
Add deflector
Browse files Browse the repository at this point in the history
  • Loading branch information
actualwitch committed Sep 16, 2024
1 parent a0e138d commit d9e1017
Show file tree
Hide file tree
Showing 24 changed files with 496 additions and 92 deletions.
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

0 comments on commit d9e1017

Please sign in to comment.