From f3e4991c765e108c8c34b2fd716a5257bc797391 Mon Sep 17 00:00:00 2001 From: Serhii Stotskyi Date: Sat, 8 Feb 2025 07:24:45 +0200 Subject: [PATCH] refactor: replaces express with hono in provider-proxy (#815) --- apps/provider-proxy/package.json | 6 +- apps/provider-proxy/src/app.ts | 65 +++++---- apps/provider-proxy/src/container.ts | 3 +- .../provider-proxy/src/routes/getAppStatus.ts | 32 ++++- .../src/routes/proxyProviderRequest.ts | 125 +++++++++++++----- package-lock.json | 56 ++++++-- packages/net/src/NetConfig.ts | 4 + 7 files changed, 203 insertions(+), 88 deletions(-) diff --git a/apps/provider-proxy/package.json b/apps/provider-proxy/package.json index 18005a11f..25b619140 100644 --- a/apps/provider-proxy/package.json +++ b/apps/provider-proxy/package.json @@ -24,9 +24,11 @@ "dependencies": { "@akashnetwork/logging": "*", "@akashnetwork/net": "*", + "@hono/node-server": "^1.13.8", + "@hono/zod-openapi": "^0.18.4", + "async-sema": "^3.1.1", "bech32": "^2.0.0", - "cors": "^2.8.5", - "express": "^4.18.2", + "hono": "^4.6.20", "lru-cache": "^11.0.2", "uuid": "^9.0.0", "ws": "^7.5.9" diff --git a/apps/provider-proxy/src/app.ts b/apps/provider-proxy/src/app.ts index d57bbc840..baae7d00d 100644 --- a/apps/provider-proxy/src/app.ts +++ b/apps/provider-proxy/src/app.ts @@ -1,16 +1,20 @@ -import cors from "cors"; -import express, { Express } from "express"; +import { serve } from "@hono/node-server"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { RegExpRouter } from "hono/router/reg-exp-router"; +import http from "http"; import { AddressInfo } from "net"; -import { getAppStatus } from "./routes/getAppStatus"; -import { proxyProviderRequest } from "./routes/proxyProviderRequest"; +import { getAppStatus, statusRoute } from "./routes/getAppStatus"; +import { proxyProviderRequest, proxyRoute } from "./routes/proxyProviderRequest"; import { WebsocketServer } from "./services/WebsocketServer"; import { container } from "./container"; -export function createApp(): Express { - const app = express(); +export function createApp(): Hono { + const app = new OpenAPIHono({ router: new RegExpRouter() }); - const whitelist = [ + const corsWhitelist = [ "http://localhost:3001", "http://localhost:3000", "https://cloudmos.grafana.net", @@ -20,41 +24,36 @@ export function createApp(): Express { "https://console-beta.akash.network" ]; - app.disable("x-powered-by"); app.use( + "/*", cors({ - origin: function (origin, callback) { - if (!origin || whitelist.indexOf(origin) !== -1) { - callback(null, true); - } else { - container.httpLogger?.warn(`Cors refused: ${origin}`); - callback(new Error("Not allowed by CORS")); - } - } + allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH", "OPTIONS"], + origin: corsWhitelist, + maxAge: 60 }) ); - app.use(express.json()); - app.get("/status", getAppStatus); - app.post("/", proxyProviderRequest); + app.openapi(statusRoute, getAppStatus); + app.openapi(proxyRoute, proxyProviderRequest as any); return app; } export async function startAppServer(port: number): Promise { - return new Promise(resolve => { - const app = createApp(); - const httpAppServer = app.listen(port, () => { - resolve({ - host: `http://localhost:${(httpAppServer.address() as AddressInfo).port}`, - close() { - wss.close(); - httpAppServer.close(); - } - }); - }); - const wss = new WebsocketServer(httpAppServer, container.certificateValidator, container.createWsLogger); - wss.listen(); - }); + const app = createApp(); + const httpAppServer = serve({ + fetch: app.fetch, + port + }) as http.Server; + const wss = new WebsocketServer(httpAppServer, container.certificateValidator, container.createWsLogger); + wss.listen(); + + return { + host: `http://localhost:${(httpAppServer.address() as AddressInfo).port}`, + close() { + wss.close(); + httpAppServer.close(); + } + }; } export interface AppServer { diff --git a/apps/provider-proxy/src/container.ts b/apps/provider-proxy/src/container.ts index 28f5282c6..76580352d 100644 --- a/apps/provider-proxy/src/container.ts +++ b/apps/provider-proxy/src/container.ts @@ -29,5 +29,6 @@ export const container = { providerProxy, certificateValidator, httpLogger, - createWsLogger + createWsLogger, + netConfig }; diff --git a/apps/provider-proxy/src/routes/getAppStatus.ts b/apps/provider-proxy/src/routes/getAppStatus.ts index b4287e678..fbc95bec7 100644 --- a/apps/provider-proxy/src/routes/getAppStatus.ts +++ b/apps/provider-proxy/src/routes/getAppStatus.ts @@ -1,10 +1,36 @@ -import { Request, Response } from "express"; +import { z } from "@hono/zod-openapi"; +import { createRoute } from "@hono/zod-openapi"; +import { Context, TypedResponse } from "hono"; import packageJson from "../../package.json"; import { container } from "../container"; import { humanFileSize } from "../sizeUtils"; -export async function getAppStatus(_: Request, res: Response): Promise { +const AppStatus = z.object({ + openClientWebSocketCount: z.number(), + totalRequestCount: z.number(), + totalTransferred: z.string(), + logStreaming: z.string(), + logDownload: z.string(), + eventStreaming: z.string(), + shell: z.string(), + version: z.string() +}); + +export const statusRoute = createRoute({ + method: "get", + path: "/status", + responses: { + 200: { + content: { + "application/json": { schema: AppStatus } + }, + description: "Retrieve app status" + } + } +}); + +export async function getAppStatus(ctx: Context): Promise, 200>> { const webSocketStats = container.wsStats.getItems(); const openClientWebSocketCount = webSocketStats.filter(x => !x.isClosed()).length; const totalRequestCount = webSocketStats.reduce((a, b) => a + b.getStats().totalStats.count, 0); @@ -35,7 +61,7 @@ export async function getAppStatus(_: Request, res: Response): Promise { data: 0 }); - res.send({ + return ctx.json({ openClientWebSocketCount, totalRequestCount, totalTransferred: humanFileSize(totalTransferred), diff --git a/apps/provider-proxy/src/routes/proxyProviderRequest.ts b/apps/provider-proxy/src/routes/proxyProviderRequest.ts index 6ed36952a..1126c3cb8 100644 --- a/apps/provider-proxy/src/routes/proxyProviderRequest.ts +++ b/apps/provider-proxy/src/routes/proxyProviderRequest.ts @@ -1,46 +1,101 @@ -import { NextFunction, Request as ExpressRequest, Response as ExpressResponse } from "express"; +import { SupportedChainNetworks } from "@akashnetwork/net"; +import { createRoute, z } from "@hono/zod-openapi"; +import { bech32 } from "bech32"; +import { Context, TypedResponse } from "hono"; +import { ClientErrorStatusCode } from "hono/utils/http-status"; +import { Readable } from "stream"; import { container } from "../container"; import { httpRetry } from "../utils/retry"; -const DEFAULT_TIMEOUT = 5_000; -export async function proxyProviderRequest(req: ExpressRequest, incommingResponse: ExpressResponse, next: NextFunction): Promise { - const { certPem, keyPem, method, body, url, network, providerAddress, timeout } = req.body; - - try { - const proxyResult = await httpRetry( - () => - container.providerProxy.connect(url, { - method, - body, - cert: certPem, - key: keyPem, - network, - providerAddress, - timeout: Number(timeout || DEFAULT_TIMEOUT) || DEFAULT_TIMEOUT - }), - { - retryIf: result => result.ok && (!result.response.statusCode || result.response.statusCode > 500) - } - ); +const RequestPayload = z.object({ + certPem: z.string().optional(), + keyPem: z.string().optional(), + method: z.enum(["GET", "POST", "PUT", "DELETE"]), + url: z.string().url(), + body: z.string().optional(), + network: z.enum(container.netConfig.getSupportedNetworks() as [SupportedChainNetworks]).describe("Blockchain network"), + providerAddress: z + .string() + .refine(v => !!bech32.decodeUnsafe(v), "is not bech32 address") + .describe("Bech32 representation of provider wallet address"), + timeout: z.number().optional() +}); - if (proxyResult.ok === false && proxyResult.code === "insecureConnection") { - incommingResponse.status(400); - incommingResponse.send("Could not establish tls connection since server responded with non-tls response"); - return; +export const proxyRoute = createRoute({ + method: "post", + path: "/", + request: { + body: { + content: { + "application/json": { + schema: RequestPayload + } + } } + }, + responses: { + 400: { + content: { + "text/plain": { + schema: z.string() + } + }, + description: "Returned if it's not possible to establish secure connection with proxied host" + }, + 495: { + // https://http.dev/495 + content: { + "text/plain": { + schema: z.string() + } + }, + description: "Returned if host SSL certificate is invalid" + } + } +}); + +const DEFAULT_TIMEOUT = 5_000; +export async function proxyProviderRequest(ctx: Context): Promise> { + const { certPem, keyPem, method, body, url, network, providerAddress, timeout } = await ctx.req.json>(); - if (proxyResult.ok === false && proxyResult.code === "invalidCertificate") { - incommingResponse.status(495); // https://http.dev/495 - incommingResponse.send(`Invalid certificate error: ${proxyResult.reason}`); - return; + const proxyResult = await httpRetry( + () => + container.providerProxy.connect(url, { + method, + body, + cert: certPem, + key: keyPem, + network, + providerAddress, + timeout: Number(timeout || DEFAULT_TIMEOUT) || DEFAULT_TIMEOUT + }), + { + retryIf: result => result.ok && (!result.response.statusCode || result.response.statusCode > 500) } + ); + + if (proxyResult.ok === false && proxyResult.code === "insecureConnection") { + return ctx.text("Could not establish tls connection since server responded with non-tls response", 400); + } - Object.keys(proxyResult.response.headers).forEach(header => { - incommingResponse.setHeader(header, proxyResult.response.headers[header] || ""); - }); - proxyResult.response.pipe(incommingResponse).on("error", next); - } catch (error) { - next(error); + if (proxyResult.ok === false && proxyResult.code === "invalidCertificate") { + return ctx.text(`Invalid certificate error: ${proxyResult.reason}`, 495 as ClientErrorStatusCode); } + + const headers = new Headers(); + Object.keys(proxyResult.response.headers).forEach(header => { + const value = proxyResult.response.headers[header]; + if (Array.isArray(value)) { + value.forEach(v => headers.set(header, v)); + } else { + headers.set(header, value); + } + }); + + return new Response(Readable.toWeb(proxyResult.response), { + status: proxyResult.response.statusCode, + statusText: proxyResult.response.statusMessage, + headers + }); } diff --git a/package-lock.json b/package-lock.json index e220ee7a5..a260f4f6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -644,9 +644,11 @@ "dependencies": { "@akashnetwork/logging": "*", "@akashnetwork/net": "*", + "@hono/node-server": "^1.13.8", + "@hono/zod-openapi": "^0.18.4", + "async-sema": "^3.1.1", "bech32": "^2.0.0", - "cors": "^2.8.5", - "express": "^4.18.2", + "hono": "^4.6.20", "lru-cache": "^11.0.2", "uuid": "^9.0.0", "ws": "^7.5.9" @@ -1063,6 +1065,35 @@ "node": ">=18" } }, + "apps/provider-proxy/node_modules/@hono/node-server": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.13.8.tgz", + "integrity": "sha512-fsn8ucecsAXUoVxrUil0m13kOEq4mkX4/4QozCqmY+HpGfKl74OYSn8JcMA8GnG0ClfdRI4/ZSeG7zhFaVg+wg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "apps/provider-proxy/node_modules/@hono/zod-openapi": { + "version": "0.18.4", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-0.18.4.tgz", + "integrity": "sha512-6NHMHU96Hh32B1yDhb94Z4Z5/POsmEu2AXpWLWcBq9arskRnOMt2752yEoXoADV8WUAc7H1IkNaQHGj1ytXbYw==", + "license": "MIT", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.1.0", + "@hono/zod-validator": "^0.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.3.6", + "zod": "3.*" + } + }, "apps/provider-proxy/node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -1127,6 +1158,15 @@ "@esbuild/win32-x64": "0.24.2" } }, + "apps/provider-proxy/node_modules/hono": { + "version": "4.6.20", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.6.20.tgz", + "integrity": "sha512-5qfNQeaIptMaJKyoJ6N/q4gIq0DBp2FCRaLNuUI3LlJKL4S37DY/rLL1uAxA4wrPB39tJ3s+f7kgI79O4ScSug==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "apps/provider-proxy/node_modules/lru-cache": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", @@ -20007,18 +20047,6 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/cosmiconfig": { "version": "7.1.0", "license": "MIT", diff --git a/packages/net/src/NetConfig.ts b/packages/net/src/NetConfig.ts index 4ad40d0b3..0b77ed577 100644 --- a/packages/net/src/NetConfig.ts +++ b/packages/net/src/NetConfig.ts @@ -6,4 +6,8 @@ export class NetConfig { getBaseAPIUrl(network: SupportedChainNetworks): string { return netConfigData[network].apiUrls[0]; } + + getSupportedNetworks(): SupportedChainNetworks[] { + return Object.keys(netConfigData) as SupportedChainNetworks[]; + } }