From 940ee0f8c1491cbd706c19a0d9bf1b5ba13777b4 Mon Sep 17 00:00:00 2001 From: huadong zuo Date: Mon, 1 Dec 2025 06:10:18 +0000 Subject: [PATCH] Add Elysia server support to grafserv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for the Elysia web framework to grafserv, enabling developers to use Elysia as a server option for their GraphQL APIs. Changes: - Added ElysiaGrafserv class with full GraphQL, GraphiQL, and WebSocket support - Implemented event handlers for GraphQL queries, GraphiQL interface, and event streams - Added Elysia as an optional peer dependency - Added export configuration for elysia/v1 in package.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- grafast/grafserv/package.json | 8 + .../grafserv/src/servers/elysia/v1/index.ts | 320 ++++++++++++++++++ yarn.lock | 3 + 3 files changed, 331 insertions(+) create mode 100644 grafast/grafserv/src/servers/elysia/v1/index.ts diff --git a/grafast/grafserv/package.json b/grafast/grafserv/package.json index 527d5897e9..5d56933ac5 100644 --- a/grafast/grafserv/package.json +++ b/grafast/grafserv/package.json @@ -46,6 +46,10 @@ "types": "./dist/servers/hono/v4/index.d.ts", "default": "./dist/servers/hono/v4/index.js" }, + "./elysia/v1": { + "types": "./dist/servers/elysia/v1/index.d.ts", + "default": "./dist/servers/elysia/v1/index.js" + }, "./whatwg-node__server/v4": { "types": "./dist/servers/whatwg-node__server/v0/index.d.ts", "default": "./dist/servers/whatwg-node__server/v0/index.js" @@ -108,6 +112,7 @@ "peerDependencies": { "@envelop/core": "^5.0.0", "@whatwg-node/server": "^0.9.64", + "elysia": "^1.0.0", "grafast": "workspace:^", "graphile-config": "workspace:^", "graphql": "^16.9.0", @@ -122,6 +127,9 @@ "@whatwg-node/server": { "optional": true }, + "elysia": { + "optional": true + }, "h3": { "optional": true }, diff --git a/grafast/grafserv/src/servers/elysia/v1/index.ts b/grafast/grafserv/src/servers/elysia/v1/index.ts new file mode 100644 index 0000000000..b468a600fb --- /dev/null +++ b/grafast/grafserv/src/servers/elysia/v1/index.ts @@ -0,0 +1,320 @@ +import { GRAPHQL_TRANSPORT_WS_PROTOCOL, makeServer } from "graphql-ws"; +import type { Context as Ctx, Elysia, ElysiaWS } from "elysia"; + +import { + convertHandlerResultToResult, + GrafservBase, + makeGraphQLWSConfig, + normalizeRequest, + processHeaders, +} from "../../../index.js"; +import type { + EventStreamHandlerResult, + GrafservBodyJSON, + GrafservConfig, + RequestDigest, + Result, +} from "../../../interfaces.js"; + +declare global { + namespace Grafast { + interface RequestContext { + elysiav1: { + ctx: Ctx; + }; + } + } +} + +function getDigest(ctx: Ctx): RequestDigest { + const req = ctx.request; + return { + httpVersionMajor: 1, // Elysia uses Fetch API, which doesn't expose HTTP version + httpVersionMinor: 1, + isSecure: req.url.startsWith("https:"), + method: req.method, + path: new globalThis.URL(req.url).pathname, + headers: processHeaders(Object.fromEntries(req.headers.entries())), + getQueryParams() { + const url = new globalThis.URL(req.url); + const params: Record = {}; + url.searchParams.forEach((value: string, key: string) => { + const existing = params[key]; + if (existing) { + if (Array.isArray(existing)) { + existing.push(value); + } else { + params[key] = [existing, value]; + } + } else { + params[key] = value; + } + }); + return params; + }, + async getBody() { + const json = await req.json(); + if (!json) { + throw new Error("Failed to retrieve body from elysia"); + } + return { + type: "json", + json, + } as GrafservBodyJSON; + }, + requestContext: { + elysiav1: { + ctx: ctx, + }, + }, + }; +} + +const utf8TextDecoder = new globalThis.TextDecoder("utf-8"); + +export class ElysiaGrafserv extends GrafservBase { + constructor(config: GrafservConfig) { + super(config); + } + + public makeWsHandler() { + const graphqlWsServer = makeServer(makeGraphQLWSConfig(this)); + + return { + message: ( + ws: ElysiaWS, + message: string | ArrayBuffer, + ) => { + let data: string; + if (typeof message === "string") { + data = message; + } else { + try { + data = utf8TextDecoder.decode(message); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`Didn't understand the data`, e); + try { + ws.close(1003, "Unsupported data"); + } catch (e) { + // eslint-disable-next-line no-console + console.error( + `Failed to close websocket, maybe it's already closed`, + e, + ); + } + return; + } + } + + ws.data.onMessage?.(data); + }, + open: (ws: ElysiaWS) => { + ws.data.onClose = graphqlWsServer.opened( + { + protocol: GRAPHQL_TRANSPORT_WS_PROTOCOL, + send(data: string) { + ws.send(data); + }, + close(code: number, reason: string) { + ws.close(code, reason); + }, + onMessage(cb: (data: string) => void) { + ws.data.onMessage = cb; + }, + }, + { socket: ws, request: ws.data.request }, + ); + }, + close: (ws: ElysiaWS, code: number, reason: string) => { + ws.data.onClose?.(code, reason); + }, + }; + } + + public async handleGraphQLEvent(ctx: Ctx) { + const digest = getDigest(ctx); + + const handlerResult = await this.graphqlHandler( + normalizeRequest(digest), + this.graphiqlHandler, + ); + const result = await convertHandlerResultToResult(handlerResult); + return this.send(ctx, result); + } + + public async handleGraphiqlEvent(ctx: Ctx) { + const digest = getDigest(ctx); + + const handlerResult = await this.graphiqlHandler(normalizeRequest(digest)); + const result = await convertHandlerResultToResult(handlerResult); + return this.send(ctx, result); + } + + public async handleGraphiqlStaticEvent(ctx: Ctx) { + const digest = getDigest(ctx); + + const handlerResult = await this.graphiqlStaticHandler( + normalizeRequest(digest), + ); + const result = await convertHandlerResultToResult(handlerResult); + return this.send(ctx, result); + } + + public async handleEventStreamEvent(ctx: Ctx) { + const digest = getDigest(ctx); + + const handlerResult: EventStreamHandlerResult = { + type: "event-stream", + request: normalizeRequest(digest), + dynamicOptions: this.dynamicOptions, + payload: this.makeStream(), + statusCode: 200, + }; + const result = await convertHandlerResultToResult(handlerResult); + return this.send(ctx, result); + } + + public async send(ctx: Ctx, result: Result | null) { + if (result === null) { + // 404 + ctx.set.status = 404; + return "¯\\_(ツ)_/¯"; + } + + switch (result.type) { + case "error": { + const { statusCode, headers } = result; + this.setResponseHeaders(ctx, headers); + ctx.set.status = statusCode; + throw result.error; + } + case "buffer": { + const { statusCode, headers, buffer } = result; + this.setResponseHeaders(ctx, headers); + ctx.set.status = statusCode; + return buffer; + } + case "json": { + const { statusCode, headers, json } = result; + this.setResponseHeaders(ctx, headers); + ctx.set.status = statusCode; + return json; + } + case "noContent": { + const { statusCode, headers } = result; + this.setResponseHeaders(ctx, headers); + ctx.set.status = statusCode; + return new globalThis.Response(null, { status: statusCode }); + } + case "bufferStream": { + const { statusCode, headers, bufferIterator } = result; + + this.setResponseHeaders(ctx, headers); + ctx.set.status = statusCode; + + // Create a ReadableStream from the buffer iterator + const stream = new globalThis.ReadableStream({ + async start(controller: ReadableStreamDefaultController) { + try { + for await (const buffer of bufferIterator) { + controller.enqueue(buffer); + } + controller.close(); + } catch (error) { + controller.error(error); + } + }, + }); + + return new globalThis.Response(stream, { + status: statusCode, + headers: headers as any, + }); + } + default: { + const never: never = result; + // eslint-disable-next-line no-console + console.log("Unhandled:"); + // eslint-disable-next-line no-console + console.dir(never); + this.setResponseHeaders(ctx, { "Content-Type": "text/plain" }); + ctx.set.status = 501; + return "Server hasn't implemented this yet"; + } + } + } + + public async addTo(app: Elysia) { + const dynamicOptions = this.dynamicOptions; + + app.post(this.dynamicOptions.graphqlPath, (c: Ctx) => + this.handleGraphQLEvent(c), + ); + + const shouldServeGetHandler = + this.dynamicOptions.graphqlOverGET || + this.dynamicOptions.graphiqlOnGraphQLGET || + this.resolvedPreset.grafserv?.websockets; + + if (shouldServeGetHandler) { + if (this.resolvedPreset.grafserv?.websockets) { + // Add WebSocket support + app.ws(this.dynamicOptions.graphqlPath, { + ...this.makeWsHandler(), + upgrade: (req: Request) => { + // Only upgrade if it's a websocket request + return { + headers: req.headers, + request: req, + }; + }, + }); + } + + // Regular GET handler for GraphQL or GraphiQL + app.get(this.dynamicOptions.graphqlPath, (c: Ctx) => + this.handleGraphQLEvent(c), + ); + } + + if (dynamicOptions.graphiql) { + app.get(this.dynamicOptions.graphiqlPath, (c: Ctx) => + this.handleGraphiqlEvent(c), + ); + } + + if (dynamicOptions.watch) { + app.get(this.dynamicOptions.eventStreamPath, (c: Ctx) => + this.handleEventStreamEvent(c), + ); + } + + if (dynamicOptions.graphiql) { + // Must come last, because wildcard + app.get(this.dynamicOptions.graphiqlStaticPath + "*", (c: Ctx) => + this.handleGraphiqlStaticEvent(c), + ); + } + } + + private setResponseHeaders(ctx: Ctx, headers: Record) { + for (const key in headers) { + if (key.toLowerCase() === "content-length") { + // Elysia takes care of setting Content-Length for us + continue; + } + ctx.set.headers[key] = headers[key]; + } + } +} + +/** + * Creates a new instance of ElysiaGrafserv. + * + * @param config - The configuration object for Grafserv. + * @returns An instance of ElysiaGrafserv. + */ +export const grafserv = (config: GrafservConfig) => { + return new ElysiaGrafserv(config); +}; diff --git a/yarn.lock b/yarn.lock index 6eac47253f..0d67c3f952 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14668,6 +14668,7 @@ __metadata: peerDependencies: "@envelop/core": ^5.0.0 "@whatwg-node/server": ^0.9.64 + elysia: ^1.0.0 grafast: "workspace:^" graphile-config: "workspace:^" graphql: ^16.9.0 @@ -14679,6 +14680,8 @@ __metadata: optional: true "@whatwg-node/server": optional: true + elysia: + optional: true h3: optional: true hono: