diff --git a/grafast/grafserv/__tests__/hono-adapter.test.ts b/grafast/grafserv/__tests__/hono-adapter.test.ts new file mode 100644 index 0000000000..aa99bcb46d --- /dev/null +++ b/grafast/grafserv/__tests__/hono-adapter.test.ts @@ -0,0 +1,162 @@ +import { serve } from "@hono/node-server"; +import { createNodeWebSocket } from "@hono/node-ws"; +import { error } from "console"; +import { constant, makeGrafastSchema } from "grafast"; +import { serverAudits } from "graphql-http"; +import { createClient } from "graphql-ws"; +import { Hono } from "hono"; +import { WebSocket } from "ws"; + +import type { GrafservConfig } from "../src/interfaces.js"; +import { grafserv } from "../src/servers/hono/v4/index.js"; + +const schema = makeGrafastSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + throwAnError: String + } + + type Subscription { + subscriptionTest: String! + } + `, + plans: { + Query: { + hello() { + return constant("world"); + }, + throwAnError() { + return error(new Error("You asked for an error... Here it is.")); + }, + }, + Subscription: { + subscriptionTest: { + // eslint-disable-next-line graphile-export/export-methods + subscribe: async function* () { + yield { subscriptionTest: "test1" }; + yield { subscriptionTest: "test2" }; + }, + }, + }, + }, +}); + +describe("Hono Adapter", () => { + // setup test server + const app = new Hono(); + const config: GrafservConfig = { + schema, // Mock schema for testing + preset: { + grafserv: { + graphqlOverGET: true, + graphqlPath: "/graphql", + dangerouslyAllowAllCORSRequests: true, + }, + }, + }; + const honoGrafserv = grafserv(config); + honoGrafserv.addTo(app); + + const server = serve({ + fetch: app.fetch, + port: 7777, + }); + const url = `http://0.0.0.0:7777/graphql`; + + it("SHOULD work for a simple request", async () => { + const res = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ query: "{ __typename }" }), + }); + + const responseBody = await res.json(); + expect(responseBody.data).toEqual({ + __typename: "Query", + }); + }); + + // run standard audits + const audits = serverAudits({ + url, + fetchFn: fetch, + }); + for (const audit of audits) { + it(audit.name, async () => { + const result = await audit.fn(); + if (audit.name.startsWith("MUST") || result.status === "ok") { + expect({ + ...result, + response: "", + }).toEqual( + expect.objectContaining({ + status: "ok", + }), + ); + } else { + console.warn(`Allowing failed test: ${audit.name}`); + } + }); + } +}); + +describe("Hono Adapter with websockets", () => { + // setup test server + const app = new Hono(); + const config: GrafservConfig = { + schema, // Mock schema for testing + preset: { + grafserv: { + graphqlOverGET: true, + websockets: true, + }, + }, + }; + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); + + const honoGrafserv = grafserv(config, upgradeWebSocket); + honoGrafserv.addTo(app); + + const server = serve({ + fetch: app.fetch, + port: 7778, + }); + injectWebSocket(server); + + const url = `ws://0.0.0.0:7778/graphql`; + + it("SHOULD work for a simple subscription", async () => { + // make a graphql subscription + const client = createClient({ + url, + webSocketImpl: WebSocket, + }); + + const query = client.iterate({ + query: "subscription { subscriptionTest }", + }); + + const { value } = await query.next(); + expect(value).toEqual({ data: { subscriptionTest: "test1" } }); + const { value: value2 } = await query.next(); + expect(value2).toEqual({ data: { subscriptionTest: "test2" } }); + }); + + it("SHOULD throw an error is websocket is enabled but no upgradeWebSocket was provided", async () => { + const config: GrafservConfig = { + schema, // Mock schema for testing + preset: { + grafserv: { + websockets: true, + }, + }, + }; + const honoGrafserv = grafserv(config); + expect(async () => { + await honoGrafserv.addTo(app); + }).rejects.toThrow(); + }); +}); diff --git a/grafast/grafserv/examples/example-hono.mjs b/grafast/grafserv/examples/example-hono.mjs new file mode 100644 index 0000000000..e11828c7d4 --- /dev/null +++ b/grafast/grafserv/examples/example-hono.mjs @@ -0,0 +1,23 @@ +import { serve } from "@hono/node-server"; +import { grafserv } from "grafserv/hono"; +import { Hono } from "hono"; + +import preset from "./graphile.config.mjs"; +import schema from "./schema.mjs"; + +// Create a Node HTTP server +const app = new Hono(); + +// Create a Grafserv instance +// the second argument is an optional websocket upgrade handler +// see https://hono.dev/docs/helpers/websocket +const serv = grafserv({ schema, preset }); + +// Mount the request handler into a new HTTP server +serv.addTo(server).catch((e) => { + console.error(e); + process.exit(1); +}); + +// Start the server with the chosen Hono adapter - here Node.js +serve(app); diff --git a/grafast/grafserv/package.json b/grafast/grafserv/package.json index 4c7cc5d14d..3bee156d67 100644 --- a/grafast/grafserv/package.json +++ b/grafast/grafserv/package.json @@ -38,6 +38,10 @@ "types": "./dist/servers/h3/v1/index.d.ts", "default": "./dist/servers/h3/v1/index.js" }, + "./hono/v4": { + "types": "./dist/servers/hono/v4/index.d.ts", + "default": "./dist/servers/hono/v4/index.js" + }, "./ruru": { "types": "./fwd/ruru/index.d.ts", "node": "./fwd/ruru/index.js", @@ -94,6 +98,7 @@ "graphile-config": "workspace:^", "graphql": "^16.1.0-experimental-stream-defer.6", "h3": "^1.13.0", + "hono": "^4.6.15", "ws": "^8.12.1" }, "peerDependenciesMeta": { @@ -103,6 +108,9 @@ "h3": { "optional": true }, + "hono": { + "optional": true + }, "ws": { "optional": true } @@ -110,6 +118,7 @@ "devDependencies": { "@envelop/core": "^5.0.0", "@fastify/websocket": "^8.2.0", + "@hono/node-server": "^1.13.7", "@types/aws-lambda": "^8.10.123", "@types/express": "^4.17.17", "@types/koa": "^2.13.8", @@ -120,13 +129,15 @@ "grafast": "workspace:^", "graphql-http": "^1.22.0", "h3": "^1.13.0", + "hono": "^4.6.15", "jest": "^29.6.4", "jest-serializer-graphql-schema": "workspace:^", "koa": "^2.14.2", "koa-bodyparser": "^4.4.1", "nodemon": "^3.0.1", "ts-node": "^10.9.1", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "ws": "^8.12.1" }, "files": [ "dist", diff --git a/grafast/grafserv/src/servers/h3/v1/index.ts b/grafast/grafserv/src/servers/h3/v1/index.ts index 7ccd5738a7..4a8c5d0f1c 100644 --- a/grafast/grafserv/src/servers/h3/v1/index.ts +++ b/grafast/grafserv/src/servers/h3/v1/index.ts @@ -278,8 +278,7 @@ export class H3Grafserv extends GrafservBase { { socket: peer.websocket, request: peer.request }, ); client.closed = async (code, reason) => { - // @ts-expect-error fixed in unreleased https://github.com/enisdenjo/graphql-ws/pull/573 - onClose(code, reason); + onClose(code as number, reason as string); }; }, message(peer, message) { diff --git a/grafast/grafserv/src/servers/hono/v4/index.ts b/grafast/grafserv/src/servers/hono/v4/index.ts new file mode 100644 index 0000000000..7e97d44be9 --- /dev/null +++ b/grafast/grafserv/src/servers/hono/v4/index.ts @@ -0,0 +1,283 @@ +import { GRAPHQL_TRANSPORT_WS_PROTOCOL, makeServer } from "graphql-ws"; +import type { Context as Ctx, Hono, MiddlewareHandler } from "hono"; +import type { StatusCode } from "hono/utils/http-status"; +import type { UpgradeWebSocket, WSContext } from "hono/ws"; + +import { + convertHandlerResultToResult, + GrafservBase, + makeGraphQLWSConfig, + normalizeRequest, + processHeaders, +} from "../../../index.js"; +import type { + EventStreamHeandlerResult, + GrafservBodyJSON, + GrafservConfig, + RequestDigest, + Result, +} from "../../../interfaces.js"; + +declare global { + namespace Grafast { + interface RequestContext { + honov4: { + ctx: Ctx; + }; + } + } +} + +function getDigest(ctx: Ctx): RequestDigest { + const req = ctx.req; + const res = ctx.res; + return { + httpVersionMajor: 1, // Hono uses Fetch API, which doesn't expose HTTP version + httpVersionMinor: 1, + isSecure: req.url.startsWith("https:"), + method: req.method, + path: req.path, + headers: processHeaders(req.header()), + getQueryParams() { + return req.query(); + }, + async getBody() { + const json = await req.json(); + if (!json) { + throw new Error("Failed to retrieve body from hono"); + } + return { + type: "json", + json, + } as GrafservBodyJSON; + }, + requestContext: { + honov4: { + ctx: ctx, + }, + node: { + // @ts-expect-error type imports + req, + res, + }, + }, + }; +} + +export class HonoGrafserv extends GrafservBase { + constructor( + config: GrafservConfig, + private upgradeWebSocket?: UpgradeWebSocket, + ) { + super(config); + } + + public makeWsHandler(upgradeWebSocket: UpgradeWebSocket): MiddlewareHandler { + const graphqlWsServer = makeServer(makeGraphQLWSConfig(this)); + return upgradeWebSocket((c) => { + let onMessage: ((data: string) => void) | undefined; + let onClose: ((code: number, reason: string) => void) | undefined; + let isOpened = false; + + const initGraphqlServer = (ws: WSContext) => { + onClose = graphqlWsServer.opened( + { + protocol: ws.protocol ?? GRAPHQL_TRANSPORT_WS_PROTOCOL, + send(data) { + ws.send(data); + }, + close(code, reason) { + console.log("close", code, reason); + ws.close(code, reason); + isOpened = false; + }, + onMessage(cb) { + onMessage = cb; + }, + }, + { socket: ws, request: c.req }, + ); + isOpened = true; + }; + + return { + onOpen(evt, ws) { + initGraphqlServer(ws); + }, + onMessage(evt, ws) { + // cloudflare workers don't support the open event + // so we initialize the server on the first message + if (!isOpened) { + initGraphqlServer(ws); + } + onMessage?.(evt.data); + }, + onClose(evt) { + onClose?.(evt.code, evt.reason); + }, + onError(evt) { + console.error("An error occured in the websocket:", evt); + }, + }; + }); + } + + /** + * @deprecated use handleGraphQLEvent instead + */ + public async handleEvent(ctx: Ctx) { + return this.handleGraphQLEvent(ctx); + } + + 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 handleEventStreamEvent(ctx: Ctx) { + const digest = getDigest(ctx); + + const handlerResult: EventStreamHeandlerResult = { + 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.status(404); + return ctx.text("¯\\_(ツ)_/¯"); + } + + switch (result.type) { + case "error": { + const { statusCode, headers } = result; + this.setResponseHeaders(ctx, headers); + ctx.status(statusCode as StatusCode); + const errorWithStatus = Object.assign(result.error, { + status: statusCode, + }); + throw errorWithStatus; + } + case "buffer": { + const { statusCode, headers, buffer } = result; + this.setResponseHeaders(ctx, headers); + ctx.status(statusCode as StatusCode); + return ctx.body(buffer); + } + case "json": { + const { statusCode, headers, json } = result; + this.setResponseHeaders(ctx, headers); + ctx.status(statusCode as StatusCode); + return ctx.json(json); + } + case "noContent": { + const { statusCode, headers } = result; + this.setResponseHeaders(ctx, headers); + ctx.status(statusCode as StatusCode); + return ctx.body(null); + } + // TODO : handle bufferStream ? + default: { + const never = result; + console.log("Unhandled:"); + console.dir(never); + this.setResponseHeaders(ctx, { "Content-Type": "text/plain" }); + ctx.status(501); + return ctx.text("Server hasn't implemented this yet"); + } + } + } + + public async addTo(app: Hono) { + const dynamicOptions = this.dynamicOptions; + + if (this.resolvedPreset.grafserv?.websockets && !this.upgradeWebSocket) { + throw new Error( + "grafserv.websockets is enabled but no upgradeWebSocket was provided", + ); + } + if (!this.resolvedPreset.grafserv?.websockets && this.upgradeWebSocket) { + console.warn( + "UpgradeWebSocket was provided but grafserv.websockets is disabled - websockets will not be activated", + ); + } + + app.post(this.dynamicOptions.graphqlPath, (c) => + this.handleGraphQLEvent(c), + ); + + const websocketHandler = + this.resolvedPreset.grafserv?.websockets && this.upgradeWebSocket + ? this.makeWsHandler(this.upgradeWebSocket) + : undefined; + + const shouldServeGetHandler = + this.dynamicOptions.graphqlOverGET || + this.dynamicOptions.graphiqlOnGraphQLGET || + websocketHandler; + + if (shouldServeGetHandler) { + app.get(this.dynamicOptions.graphqlPath, (c, next) => { + if (c.req.header("Upgrade") === "websocket" && websocketHandler) { + return websocketHandler(c, next); + } + return this.handleGraphQLEvent(c); + }); + } + + if (dynamicOptions.graphiql) { + app.get(this.dynamicOptions.graphiqlPath, (c) => + this.handleGraphiqlEvent(c), + ); + } + + if (dynamicOptions.watch) { + app.get(this.dynamicOptions.eventStreamPath, (c) => + this.handleEventStreamEvent(c), + ); + } + } + + private setResponseHeaders(ctx: Ctx, headers: Record) { + for (const key in headers) { + ctx.header(key, headers[key]); + } + } +} + +/** + * Creates a new instance of HonoGrafserv. + * + * @param config - The configuration object for Grafserv. + * @param upgradeWebSocket - Optional parameter required when using websockets. + * Hono uses the upgradeWebsocket helper depending on the environment. + * Refer to https://hono.dev/docs/helpers/websocket for more details. + * @returns An instance of HonoGrafserv. + */ +export const grafserv = ( + config: GrafservConfig, + upgradeWebSocket?: UpgradeWebSocket, +) => { + return new HonoGrafserv(config, upgradeWebSocket); +}; diff --git a/grafast/website/grafserv/servers/hono.md b/grafast/website/grafserv/servers/hono.md new file mode 100644 index 0000000000..21265ba3a2 --- /dev/null +++ b/grafast/website/grafserv/servers/hono.md @@ -0,0 +1,29 @@ +# Hono + +**THIS INTEGRATION IS EXPERIMENTAL**. PRs improving it are welcome. + +```ts +import { grafserv } from "grafserv/hono"; +import preset from "./graphile.config.mjs"; +import schema from "./schema.mjs"; + +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; + +// Create a Node HTTP server +const app = new Hono(); + +// Create a Grafserv instance +// the second argument is an optional websocket upgrade handler +// see https://hono.dev/docs/helpers/websocket +const serv = grafserv({ schema, preset }); + +// Mount the request handler into a new HTTP server +serv.addTo(server).catch((e) => { + console.error(e); + process.exit(1); +}); + +// Start the server with the chosen Hono adapter - here Node.js +serve(app); +``` diff --git a/package.json b/package.json index fd7a7b3aa5..f23cb55405 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@babel/preset-env": "^7.25.4", "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", + "@hono/node-ws": "^1.0.5", "@knodes/typedoc-plugin-monorepo-readmes": "^0.23.1", "@knodes/typedoc-plugin-pages": "^0.23.4", "@localrepo/prettier2-for-jest": "npm:prettier@^2", @@ -44,6 +45,7 @@ "@types/mock-fs": "4.13.1", "@types/node": "^20.5.7", "@types/rimraf": "^4.0.5", + "@types/ws": "^8", "@typescript-eslint/eslint-plugin": "^6.5.0", "@typescript-eslint/parser": "^6.5.0", "@typescript-eslint/typescript-estree": "^6.5.0", @@ -63,6 +65,7 @@ "eslint_d": "^12.2.1", "glob": "^10.3.4", "graphql": "16.1.0-experimental-stream-defer.6", + "graphql-ws": "^5.16.0", "jest": "^29.6.4", "mock-fs": "^5.2.0", "pg": "^8.11.3", @@ -72,6 +75,7 @@ "typedoc-monorepo-link-types": "^0.0.4", "typescript": "^5.2.2", "webpack": "^5.94.0", + "ws": "^8.18.0", "zx": "^7.2.3" }, "workspaces": [ diff --git a/yarn.lock b/yarn.lock index 9bf846ab99..4f3b5b6f13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3495,6 +3495,26 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.13.7": + version: 1.13.7 + resolution: "@hono/node-server@npm:1.13.7" + peerDependencies: + hono: ^4 + checksum: 314671906b6a89f4611f7c7949c262287feff4a39c654c6831f78af7e8286537a97926849a7acdda654bea72b6a0d533b6df3f13a2b75325516a94393f8a6c8d + languageName: node + linkType: hard + +"@hono/node-ws@npm:^1.0.5": + version: 1.0.5 + resolution: "@hono/node-ws@npm:1.0.5" + dependencies: + ws: "npm:^8.17.0" + peerDependencies: + "@hono/node-server": ^1.11.1 + checksum: 0126850d73984ab71211c3841731fc493abaf92a8d15a17e36d5ddda8d85d04efcd29c0cff894cad19dc69c6bcca8ebdae9ed0bc43e3782322474231db5dc604 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.10": version: 0.11.11 resolution: "@humanwhocodes/config-array@npm:0.11.11" @@ -6036,6 +6056,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8": + version: 8.5.13 + resolution: "@types/ws@npm:8.5.13" + dependencies: + "@types/node": "npm:*" + checksum: 8604665cd238ae0aae420fbdbb8065d8cefb5eb461a5108022102ace01eac662bf29a0a440bd6caf8f135d5ffac470e668705d44140e6b6a9d43cf561dff5a36 + languageName: node + linkType: hard + "@types/ws@npm:^8.5.5": version: 8.5.5 resolution: "@types/ws@npm:8.5.5" @@ -11970,6 +11999,7 @@ __metadata: "@envelop/core": "npm:^5.0.0" "@fastify/websocket": "npm:^8.2.0" "@graphile/lru": "workspace:^" + "@hono/node-server": "npm:^1.13.7" "@types/aws-lambda": "npm:^8.10.123" "@types/express": "npm:^4.17.17" "@types/koa": "npm:^2.13.8" @@ -11984,6 +12014,7 @@ __metadata: graphql-http: "npm:^1.22.0" graphql-ws: "npm:^5.14.0" h3: "npm:^1.13.0" + hono: "npm:^4.6.15" jest: "npm:^29.6.4" jest-serializer-graphql-schema: "workspace:^" koa: "npm:^2.14.2" @@ -11993,18 +12024,22 @@ __metadata: ts-node: "npm:^10.9.1" tslib: "npm:^2.6.2" typescript: "npm:^5.2.2" + ws: "npm:^8.12.1" peerDependencies: "@envelop/core": ^5.0.0 grafast: "workspace:^" graphile-config: "workspace:^" graphql: ^16.1.0-experimental-stream-defer.6 h3: ^1.13.0 + hono: ^4.6.15 ws: ^8.12.1 peerDependenciesMeta: "@envelop/core": optional: true h3: optional: true + hono: + optional: true ws: optional: true languageName: unknown @@ -12309,6 +12344,15 @@ __metadata: languageName: node linkType: hard +"graphql-ws@npm:^5.16.0": + version: 5.16.0 + resolution: "graphql-ws@npm:5.16.0" + peerDependencies: + graphql: ">=0.11 <=16" + checksum: 6184e5b25e0b62f7cfd987176e09379fd7d8c900fc474a9cc1d79403a852ee49622e62c43ca9e4738113f9f7c29bcd82a80414f06c5cc9d2322d6eb846d20e59 + languageName: node + linkType: hard + "graphql@npm:16.1.0-experimental-stream-defer.6": version: 16.1.0-experimental-stream-defer.6 resolution: "graphql@npm:16.1.0-experimental-stream-defer.6" @@ -12587,6 +12631,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.6.15": + version: 4.6.15 + resolution: "hono@npm:4.6.15" + checksum: 3c9c4be676e90929eeec97cbea48786c4c76bc32c3a5fcc445db877acecd030c0091bfecd9b7463b9a38cf418c9c7745b37d92c9a2094250c8d7c3babe03f4e6 + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -19073,6 +19124,7 @@ __metadata: "@babel/preset-typescript": "npm:^7.24.7" "@changesets/changelog-github": "npm:^0.4.8" "@changesets/cli": "npm:^2.26.2" + "@hono/node-ws": "npm:^1.0.5" "@knodes/typedoc-plugin-monorepo-readmes": "npm:^0.23.1" "@knodes/typedoc-plugin-pages": "npm:^0.23.4" "@localrepo/prettier2-for-jest": "npm:prettier@^2" @@ -19081,6 +19133,7 @@ __metadata: "@types/mock-fs": "npm:4.13.1" "@types/node": "npm:^20.5.7" "@types/rimraf": "npm:^4.0.5" + "@types/ws": "npm:^8" "@typescript-eslint/eslint-plugin": "npm:^6.5.0" "@typescript-eslint/parser": "npm:^6.5.0" "@typescript-eslint/typescript-estree": "npm:^6.5.0" @@ -19100,6 +19153,7 @@ __metadata: eslint_d: "npm:^12.2.1" glob: "npm:^10.3.4" graphql: "npm:16.1.0-experimental-stream-defer.6" + graphql-ws: "npm:^5.16.0" jest: "npm:^29.6.4" mock-fs: "npm:^5.2.0" pg: "npm:^8.11.3" @@ -19109,6 +19163,7 @@ __metadata: typedoc-monorepo-link-types: "npm:^0.0.4" typescript: "npm:^5.2.2" webpack: "npm:^5.94.0" + ws: "npm:^8.18.0" zx: "npm:^7.2.3" languageName: unknown linkType: soft @@ -22399,7 +22454,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.0.0, ws@npm:^8.13.0, ws@npm:^8.17.1": +"ws@npm:^8.0.0, ws@npm:^8.12.1, ws@npm:^8.13.0, ws@npm:^8.17.0, ws@npm:^8.17.1, ws@npm:^8.18.0": version: 8.18.0 resolution: "ws@npm:8.18.0" peerDependencies: