feat(api): custom routes

This commit is contained in:
Tat Dat Duong
2025-04-11 16:40:40 +02:00
parent cf7e104e70
commit 9bec26b41a
9 changed files with 205 additions and 53 deletions
+9
View File
@@ -0,0 +1,9 @@
import { Hono } from "hono";
const api = new Hono();
api.get("/info", (c) => c.json({ flags: { assistants: true, crons: false } }));
api.get("/ok", (c) => c.json({ ok: true }));
export default api;
+1 -1
View File
@@ -12,7 +12,7 @@ import {
declare module "hono" {
interface ContextVariableMap {
auth: AuthContext | undefined;
auth?: AuthContext | undefined;
}
}
+18
View File
@@ -13,6 +13,23 @@ export async function spawnServer(
ui?: Record<string, string>;
ui_config?: { shared?: string[] };
auth?: { path?: string; disable_studio_auth?: boolean };
http?: {
app?: string;
disable_assistants?: boolean;
disable_threads?: boolean;
disable_runs?: boolean;
disable_store?: boolean;
disable_meta?: boolean;
cors?: {
allow_origins?: string[];
allow_methods?: string[];
allow_headers?: string[];
allow_credentials?: boolean;
allow_origin_regex?: string;
expose_headers?: string[];
max_age?: number;
};
};
};
env: NodeJS.ProcessEnv;
hostUrl: string;
@@ -61,6 +78,7 @@ For production use, please use LangGraph Cloud.
ui: context.config.ui,
ui_config: context.config.ui_config,
cwd: options.projectCwd,
http: context.config.http,
}),
],
{
+15
View File
@@ -0,0 +1,15 @@
import { Hono } from "hono";
import * as path from "node:path";
import * as url from "node:url";
export async function registerHttp(appPath: string, options: { cwd: string }) {
const [userFile, exportSymbol] = appPath.split(":", 2);
const sourceFile = path.resolve(options.cwd, userFile);
const user = (await import(url.pathToFileURL(sourceFile).toString()).then(
(module) => module[exportSymbol || "default"],
)) as Hono | undefined;
if (!user) throw new Error(`Failed to load HTTP app: ${appPath}`);
return { api: user };
}
@@ -0,0 +1,57 @@
import { MiddlewareHandler } from "hono";
import { cors as honoCors } from "hono/cors";
export const cors = (
cors:
| {
allow_origins?: string[];
allow_origin_regex?: string;
allow_methods?: string[];
allow_headers?: string[];
allow_credentials?: boolean;
expose_headers?: string[];
max_age?: number;
}
| undefined,
): MiddlewareHandler => {
if (cors == null) return honoCors();
const originRegex = cors.allow_origin_regex
? new RegExp(cors.allow_origin_regex)
: undefined;
const origin = originRegex
? (origin: string) => {
originRegex.lastIndex = 0; // reset regex in case it's a global regex
if (originRegex.test(origin)) return origin;
return undefined;
}
: (cors.allow_origins ?? []);
// TODO: handle `cors.allow_credentials`
return honoCors({
origin,
maxAge: cors.max_age,
allowMethods: cors.allow_methods,
allowHeaders: cors.allow_headers,
credentials: cors.allow_credentials,
exposeHeaders: cors.expose_headers,
});
};
// This is used to match the behavior of the original LangGraph API
// where the content-type is not being validated. Might be nice
// to warn about this in the future and throw an error instead.
export const ensureContentType = (): MiddlewareHandler => {
return async (c, next) => {
if (
c.req.header("content-type")?.startsWith("text/plain") &&
c.req.method !== "GET" &&
c.req.method !== "OPTIONS"
) {
c.req.raw.headers.set("content-type", "application/json");
}
await next();
};
};
+65 -52
View File
@@ -1,6 +1,5 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { registerFromEnv } from "./graph/load.mjs";
@@ -8,6 +7,7 @@ import runs from "./api/runs.mjs";
import threads from "./api/threads.mjs";
import assistants from "./api/assistants.mjs";
import store from "./api/store.mjs";
import meta from "./api/meta.mjs";
import { truncate, conn as opsConn } from "./storage/ops.mjs";
import { zValidator } from "@hono/zod-validator";
@@ -18,54 +18,8 @@ import { checkpointer } from "./storage/checkpoint.mjs";
import { store as graphStore } from "./storage/store.mjs";
import { auth } from "./auth/custom.mjs";
import { registerAuth } from "./auth/index.mjs";
const app = new Hono();
// This is used to match the behavior of the original LangGraph API
// where the content-type is not being validated. Might be nice
// to warn about this in the future and throw an error instead.
app.use(async (c, next) => {
if (
c.req.header("content-type")?.startsWith("text/plain") &&
c.req.method !== "GET" &&
c.req.method !== "OPTIONS"
) {
c.req.raw.headers.set("content-type", "application/json");
}
await next();
});
app.use(cors());
app.use(requestLogger());
app.get("/info", (c) => c.json({ flags: { assistants: true, crons: false } }));
app.post(
"/internal/truncate",
zValidator(
"json",
z.object({
runs: z.boolean().optional(),
threads: z.boolean().optional(),
assistants: z.boolean().optional(),
checkpointer: z.boolean().optional(),
store: z.boolean().optional(),
}),
),
(c) => {
const { runs, threads, assistants, checkpointer, store } =
c.req.valid("json");
truncate({ runs, threads, assistants, checkpointer, store });
return c.json({ ok: true });
},
);
app.use(auth());
app.route("/", assistants);
app.route("/", runs);
app.route("/", threads);
app.route("/", store);
import { registerHttp } from "./http/custom.mjs";
import { cors, ensureContentType } from "./http/middleware.mjs";
export const StartServerSchema = z.object({
port: z.number(),
@@ -81,6 +35,27 @@ export const StartServerSchema = z.object({
.optional(),
ui: z.record(z.string()).optional(),
ui_config: z.object({ shared: z.array(z.string()).optional() }).optional(),
http: z
.object({
app: z.string().optional(),
disable_assistants: z.boolean().default(false),
disable_threads: z.boolean().default(false),
disable_runs: z.boolean().default(false),
disable_store: z.boolean().default(false),
disable_meta: z.boolean().default(false),
cors: z
.object({
allow_origins: z.array(z.string()).optional(),
allow_methods: z.array(z.string()).optional(),
allow_headers: z.array(z.string()).optional(),
allow_credentials: z.boolean().optional(),
allow_origin_regex: z.string().optional(),
expose_headers: z.array(z.string()).optional(),
max_age: z.number().optional(),
})
.optional(),
})
.optional(),
});
export async function startServer(options: z.infer<typeof StartServerSchema>) {
@@ -99,21 +74,59 @@ export async function startServer(options: z.infer<typeof StartServerSchema>) {
logger.info(`Registering graphs from ${options.cwd}`);
await registerFromEnv(options.graphs, { cwd: options.cwd });
const app = new Hono();
if (options.auth?.path) {
logger.info(`Loading auth from ${options.auth.path}`);
await registerAuth(options.auth, { cwd: options.cwd });
app.use(auth());
}
if (options.ui) {
logger.info(`Loading UI`);
const { api, registerGraphUi } = await import("./ui/load.mjs");
if (options.http?.app) {
logger.info(`Loading HTTP app from ${options.http.app}`);
const { api } = await registerHttp(options.http.app, { cwd: options.cwd });
app.route("/", api);
}
app.use(cors(options.http?.cors));
app.use(requestLogger());
app.use(ensureContentType());
app.post(
"/internal/truncate",
zValidator(
"json",
z.object({
runs: z.boolean().optional(),
threads: z.boolean().optional(),
assistants: z.boolean().optional(),
checkpointer: z.boolean().optional(),
store: z.boolean().optional(),
}),
),
(c) => {
const { runs, threads, assistants, checkpointer, store } =
c.req.valid("json");
truncate({ runs, threads, assistants, checkpointer, store });
return c.json({ ok: true });
},
);
if (!options.http?.disable_meta) app.route("/", meta);
if (!options.http?.disable_assistants) app.route("/", assistants);
if (!options.http?.disable_runs) app.route("/", runs);
if (!options.http?.disable_threads) app.route("/", threads);
if (!options.http?.disable_store) app.route("/", store);
if (options.ui) {
logger.info(`Registering UI from ${options.cwd}`);
const { api, registerGraphUi } = await import("./ui/load.mjs");
await registerGraphUi(options.ui, {
cwd: options.cwd,
config: options.ui_config,
});
app.route("/", api);
}
logger.info(`Starting ${options.nWorkers} workers`);
+3
View File
@@ -0,0 +1,3 @@
import { Hono } from "hono";
export const app = new Hono().get("/info", (c) => c.json({ random: true }));
@@ -0,0 +1,16 @@
{
"node_version": "20",
"graphs": {
"agent": "./agent.mts:graph",
"agent_simple": "./agent_simple.mts:graph"
},
"ui": {
"agent": "./agent/ui.tsx"
},
"http": {
"app": "./http.mts:app"
},
"env": {
"LANGGRAPH_CONFIG": "{\"agent\": {\"configurable\": {\"model_name\": \"openai\"}}}"
}
}
+21
View File
@@ -32,6 +32,27 @@ const BaseConfigSchema = z.object({
disable_studio_auth: z.boolean().default(false),
})
.optional(),
http: z
.object({
app: z.string().optional(),
disable_assistants: z.boolean().default(false),
disable_threads: z.boolean().default(false),
disable_runs: z.boolean().default(false),
disable_store: z.boolean().default(false),
disable_meta: z.boolean().default(false),
cors: z
.object({
allow_origins: z.array(z.string()).optional(),
allow_methods: z.array(z.string()).optional(),
allow_headers: z.array(z.string()).optional(),
allow_credentials: z.boolean().optional(),
allow_origin_regex: z.string().optional(),
expose_headers: z.array(z.string()).optional(),
max_age: z.number().optional(),
})
.optional(),
})
.optional(),
});
const DEFAULT_PYTHON_VERSION = "3.11" as const;