Skip to content

Commit 017a087

Browse files
authored
🤖 feat: add OpenAPI spec and Scalar API docs endpoints (#906)
## Summary - Add `/api/spec.json` endpoint serving OpenAPI 3.x spec generated from oRPC router - Add `/api/docs` endpoint with Scalar API reference UI - Add OpenAPI REST handler for Scalar/OpenAPI clients - Return `specUrl` and `docsUrl` from server start _Generated with `mux`_
1 parent 2c91806 commit 017a087

File tree

4 files changed

+84
-2
lines changed

4 files changed

+84
-2
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@mozilla/readability": "^0.6.0",
1515
"@openrouter/ai-sdk-provider": "^1.2.5",
1616
"@orpc/client": "^1.11.3",
17+
"@orpc/openapi": "^1.12.2",
1718
"@orpc/server": "^1.11.3",
1819
"@orpc/zod": "^1.11.3",
1920
"@radix-ui/react-checkbox": "^1.3.3",

jest.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ module.exports = {
1818
transform: {
1919
"^.+\\.(ts|tsx|js|mjs)$": ["babel-jest"],
2020
},
21-
// Transform ESM modules (like shiki, @orpc) to CommonJS for Jest
22-
transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki)/)"],
21+
// Transform ESM modules to CommonJS for Jest
22+
transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki|json-schema-typed|rou3)/)"],
2323
// Run tests in parallel (use 50% of available cores, or 4 minimum)
2424
maxWorkers: "50%",
2525
// Force exit after tests complete to avoid hanging on lingering handles

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@mozilla/readability": "^0.6.0",
5656
"@openrouter/ai-sdk-provider": "^1.2.5",
5757
"@orpc/client": "^1.11.3",
58+
"@orpc/openapi": "^1.12.2",
5859
"@orpc/server": "^1.11.3",
5960
"@orpc/zod": "^1.11.3",
6061
"@radix-ui/react-checkbox": "^1.3.3",

src/node/orpc/server.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { WebSocketServer } from "ws";
1313
import { RPCHandler } from "@orpc/server/node";
1414
import { RPCHandler as ORPCWebSocketServerHandler } from "@orpc/server/ws";
1515
import { onError } from "@orpc/server";
16+
import { OpenAPIGenerator } from "@orpc/openapi";
17+
import { OpenAPIHandler } from "@orpc/openapi/node";
18+
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
1619
import { router, type AppRouter } from "@/node/orpc/router";
1720
import type { ORPCContext } from "@/node/orpc/context";
1821
import { extractWsHeaders } from "@/node/orpc/authMiddleware";
@@ -53,6 +56,10 @@ export interface OrpcServer {
5356
baseUrl: string;
5457
/** WebSocket URL for WS connections */
5558
wsUrl: string;
59+
/** URL for OpenAPI spec JSON */
60+
specUrl: string;
61+
/** URL for Scalar API docs */
62+
docsUrl: string;
5663
/** Close the server and cleanup resources */
5764
close: () => Promise<void>;
5865
}
@@ -100,6 +107,77 @@ export async function createOrpcServer({
100107

101108
const orpcRouter = existingRouter ?? router(authToken);
102109

110+
// OpenAPI generator for spec endpoint
111+
const openAPIGenerator = new OpenAPIGenerator({
112+
schemaConverters: [new ZodToJsonSchemaConverter()],
113+
});
114+
115+
// OpenAPI spec endpoint
116+
app.get("/api/spec.json", async (_req, res) => {
117+
const spec = await openAPIGenerator.generate(orpcRouter, {
118+
info: {
119+
title: "Mux API",
120+
version: VERSION.git_describe,
121+
description: "API for Mux",
122+
},
123+
servers: [{ url: "/api" }],
124+
security: authToken ? [{ bearerAuth: [] }] : undefined,
125+
components: authToken
126+
? {
127+
securitySchemes: {
128+
bearerAuth: {
129+
type: "http",
130+
scheme: "bearer",
131+
},
132+
},
133+
}
134+
: undefined,
135+
});
136+
res.json(spec);
137+
});
138+
139+
// Scalar API reference UI
140+
app.get("/api/docs", (_req, res) => {
141+
const html = `<!doctype html>
142+
<html>
143+
<head>
144+
<title>mux API Reference</title>
145+
<meta charset="utf-8" />
146+
<meta name="viewport" content="width=device-width, initial-scale=1" />
147+
</head>
148+
<body>
149+
<div id="app"></div>
150+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
151+
<script>
152+
Scalar.createApiReference('#app', {
153+
url: '/api/spec.json',
154+
${authToken ? "authentication: { securitySchemes: { bearerAuth: { token: '' } } }," : ""}
155+
})
156+
</script>
157+
</body>
158+
</html>`;
159+
res.setHeader("Content-Type", "text/html");
160+
res.send(html);
161+
});
162+
163+
// OpenAPI REST handler (for Scalar/OpenAPI clients)
164+
const openAPIHandler = new OpenAPIHandler(orpcRouter, {
165+
interceptors: [onError(onOrpcError)],
166+
});
167+
168+
app.use("/api", async (req, res, next) => {
169+
// Skip spec.json and docs routes - they're handled above
170+
if (req.path === "/spec.json" || req.path === "/docs") {
171+
return next();
172+
}
173+
const { matched } = await openAPIHandler.handle(req, res, {
174+
prefix: "/api",
175+
context: { ...context, headers: req.headers },
176+
});
177+
if (matched) return;
178+
next();
179+
});
180+
103181
// oRPC HTTP handler
104182
const orpcHandler = new RPCHandler(orpcRouter, {
105183
interceptors: [onError(onOrpcError)],
@@ -161,6 +239,8 @@ export async function createOrpcServer({
161239
port: actualPort,
162240
baseUrl: `http://${connectableHost}:${actualPort}`,
163241
wsUrl: `ws://${connectableHost}:${actualPort}/orpc/ws`,
242+
specUrl: `http://${connectableHost}:${actualPort}/api/spec.json`,
243+
docsUrl: `http://${connectableHost}:${actualPort}/api/docs`,
164244
close: async () => {
165245
// Close WebSocket server first
166246
wsServer.close();

0 commit comments

Comments
 (0)