Skip to content

Commit

Permalink
Support ws subscriptions for nuxt and h3 (with doc)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dominique BILLET committed Oct 4, 2024
1 parent 697cfad commit ae7e535
Show file tree
Hide file tree
Showing 4 changed files with 54 additions and 172 deletions.
4 changes: 2 additions & 2 deletions grafast/grafserv/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"grafast": "workspace:^",
"graphile-config": "workspace:^",
"graphql": "^16.1.0-experimental-stream-defer.6",
"h3": "^1.7.1",
"h3": "^1.13.0",
"ws": "^8.12.1"
},
"peerDependenciesMeta": {
Expand All @@ -119,7 +119,7 @@
"fastify": "^4.22.1",
"grafast": "workspace:^",
"graphql-http": "^1.22.0",
"h3": "^1.8.1",
"h3": "^1.13.0",
"jest": "^29.6.4",
"jest-serializer-graphql-schema": "workspace:^",
"koa": "^2.14.2",
Expand Down
86 changes: 31 additions & 55 deletions grafast/grafserv/src/servers/h3/v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { IncomingMessage, Server as HTTPServer } from "node:http";
import type { Server as HTTPSServer } from "node:https";
import type { Duplex } from "node:stream";
import { PassThrough } from "node:stream";

//@ts-expect-error type imports.
import type { Hooks, Peer } from 'crossws';
import { GRAPHQL_TRANSPORT_WS_PROTOCOL, makeServer } from 'graphql-ws';
import type { App, H3Event } from "h3";
import {
createRouter,
Expand All @@ -20,6 +20,7 @@ import {
import {
convertHandlerResultToResult,
GrafservBase,
makeGraphQLWSConfig,
normalizeRequest,
processHeaders,
} from "../../../index.js";
Expand All @@ -30,7 +31,6 @@ import type {
RequestDigest,
Result,
} from "../../../interfaces.js";
import { makeNodeUpgradeHandler } from "../../node/index.js";

declare global {
namespace Grafast {
Expand Down Expand Up @@ -242,58 +242,34 @@ export class H3Grafserv extends GrafservBase {
}
}

async getUpgradeHandler_experimental() {
if (this.resolvedPreset.grafserv?.websockets) {
return makeNodeUpgradeHandler(this);
} else {
return null;
}
}

shouldHandleUpgrade_experimental(
req: IncomingMessage,
_socket: Duplex,
_head: Buffer,
) {
const fullUrl = req.url;
if (!fullUrl) {
return false;
}
const q = fullUrl.indexOf("?");
const url = q >= 0 ? fullUrl.substring(0, q) : fullUrl;
const graphqlPath = this.dynamicOptions.graphqlPath;
return url === graphqlPath;
}

public async addTo_experimental(
app: App,
server: HTTPServer | HTTPSServer | undefined,
addExclusiveWebsocketHandler = true,
) {
this.addTo(app);

if (addExclusiveWebsocketHandler && server) {
await this.attachWebsocketsToServer_experimental(server);
}
}

public async attachWebsocketsToServer_experimental(
server: HTTPServer | HTTPSServer,
) {
const grafservUpgradeHandler = await this.getUpgradeHandler_experimental();
if (grafservUpgradeHandler) {
const upgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
if (this.shouldHandleUpgrade_experimental(req, socket, head)) {
grafservUpgradeHandler(req, socket, head);
} else {
socket.destroy();
}
};
server.on("upgrade", upgrade);
this.onRelease(() => {
server.off("upgrade", upgrade);
});
public makeWsHandler(): Partial<Hooks> {
const graphqlWsServer = makeServer(makeGraphQLWSConfig(this));
interface Client {
handleMessage?: (data: string) => Promise<void>;
closed?: (code?: number, reason?: string) => Promise<void>;
}

const clients = new WeakMap<Peer, Client>();
return {
open: async (peer) => {
const client: Client = {};
//@ts-expect-error Close code and reason are optional for close (https://github.com/enisdenjo/graphql-ws/pull/573)
client.closed = graphqlWsServer.opened(
{
protocol: peer.websocket.protocol ?? GRAPHQL_TRANSPORT_WS_PROTOCOL, // will be validated
send: (data) => {
peer.send(data);
},
close: (code, reason) => peer.close(code, reason), // there are protocol standard closures
onMessage: (cb) => (client.handleMessage = cb),
},
{ socket: peer.websocket, request: peer.request }
);
clients.set(peer, client);
},
message: (peer, message) => clients.get(peer)?.handleMessage?.(message.text()),
close: (peer, details) => clients.get(peer)?.closed?.(details.code, details.reason),
};
}
}

Expand Down
35 changes: 9 additions & 26 deletions grafast/website/grafserv/servers/h3.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

```ts
import { createServer } from "node:http";
import { createApp, eventHandler, toNodeListener } from "h3";
import {
createApp,
defineWebSocketHandler,
eventHandler,
toNodeListener,
} from "h3";
import { grafserv } from "grafserv/h3/v1";
import preset from "./graphile.config";
import schema from "./schema.mjs";
Expand All @@ -26,31 +31,9 @@ serv.addTo(app).catch((e) => {
process.exit(1);
});

// If you need websocket subscriptions (need h3@^1.13.0);
app.use("/path_to_ws_handler", defineWebSocketHandler(serv.makeWsHandler()));

// Start the server
server.listen(preset.grafserv?.port ?? 5678);
```

# Experimental

## Websocket support

h3 does not yet support WebSocket.

An unofficial and experimental workaround consists to replace

```ts
serv.addTo(app).catch((e) => {
console.error(e);
process.exit(1);
});
```

with

```ts
// this method register directly `server.on('upgrade', ...)` for handling websockets by postgraphile
serv.addTo_experimental(app, server).catch((e) => {
console.error(e);
process.exit(1);
});
```
101 changes: 12 additions & 89 deletions grafast/website/grafserv/servers/nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,101 +40,24 @@ import { serv } from "@/server/grafserv/serv";
export default eventHandler((event) => serv.handleEventStreamEvent(event));
```

# Experimental

## Websocket support

Nitro and h3 does not yet support WebSocket.

An unofficial and experimental workaround consists to create a nuxt module:

```ts title="modules/grafserv/index.ts"
// nuxt auto-register modules located in `modules/*.ts` or `modules/*/index.ts`

import { defineNuxtModule, addServerPlugin } from "@nuxt/kit";

import httpProxy from "http-proxy";

export default defineNuxtModule({
async setup(options, nuxt) {
/**
* Register websockets in DEVELOPMENT mode.
*/
if (nuxt.options.dev) {
// hook called in development only
nuxt.hook("listen", (devServer) => {
// create a proxy for routing ws to runtime server created in dev plugin
const proxy = httpProxy.createProxy({
target: {
host: "localhost",
port: 3100,
},
});
// registering ws on devServer
devServer.on("upgrade", (req, socket, head) => {
// routing ws by path
switch (req.url) {
case "/api/graphql":
// proxy websocket to runtime server created in dev plugin
proxy.ws(req, socket, head);
break;
default:
socket.destroy();
}
});
});
// Registering runtime plugin for dev
addServerPlugin("@/modules/grafserv/ws-dev");
}

/**
* Register websockets in PRODUCTION mode.
*/
if (!nuxt.options.dev)
// Registering runtime plugin for production
addServerPlugin("@/modules/grafserv/ws");
},
});
```

and two Nitro plugins (one for dev, and one for prod)
## Websocket support (need h3@^1.13.0)

```ts title="modules/grafserv/ws-dev.ts"
import { Server } from "http";
```ts title="server/api/graphql-ws.ts"
import { serv } from "@/server/grafserv/serv";

// plugin running in DEVELOPMENT (runtime)
export default defineNitroPlugin(async (nitroApp) => {
// Create a server for handling websockets
const server = new Server().listen({ port: 3100 }, () =>
console.log("Runtime server listening on port 3100"),
);
// Cleanly close server (on leave or HMR before plugin reload)
nitroApp.hooks.hookOnce("close", () => {
server.closeAllConnections();
server.close((err) =>
err
? console.warn("Runtime server wrongly closed", err)
: console.log("Runtime server stopped"),
);
});
// Attaching ws to server
serv.attachWebsocketsToServer_experimental(server);
});
// Create and export the `/api/graphql` websocket handler
export default defineWebSocketHandler(serv.makeWsHandler());
```

```ts title="modules/grafserv/ws.ts"
If the ws endpoint and the graphql endpoint share the same path:

```ts title="server/api/graphql.ts"
import { serv } from "@/server/grafserv/serv";

// plugin running in PRODUCTION (runtime)
export default defineNitroPlugin(async (nitroApp) => {
// this hook will be called only once at first http request
nitroApp.hooks.hookOnce("request", (event: H3Event) => {
const server = event.node.req.socket.server;
if (server) {
// Attaching ws to server
serv.attachWebsocketsToServer_experimental(server);
}
});
export default eventHandler({
// Create and export the `/api/graphql` route handler
handler: (event) => serv.handleGraphQLEvent(event),
// Create and export the `/api/graphql` websocket handler
websocket: serv.makeWsHandler(),
});
```

0 comments on commit ae7e535

Please sign in to comment.