Skip to content

Commit

Permalink
enhance(serve): fallback to node:http when uWebSockets is not avail…
Browse files Browse the repository at this point in the history
…able (#6936)

* enhance(serve): fallback to `node:http` if uWebSockets fails to start

* Better logic

* Add tests to artifacts integration test

* Fix imports

* Test HTTP connection

* Do not check the response

* chore(dependencies): updated changesets for modified dependencies

* Fix leak tests

* Try another hostname

* Remove another step

* Nevermind

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
ardatan and github-actions[bot] authored May 22, 2024
1 parent 039badd commit c4d2249
Show file tree
Hide file tree
Showing 18 changed files with 423 additions and 94 deletions.
6 changes: 6 additions & 0 deletions .changeset/@graphql-mesh_cli-6936-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@graphql-mesh/cli": patch
---
dependencies updates:
- Added dependency [`ws@^8.17.0` ↗︎](https://www.npmjs.com/package/ws/v/8.17.0) (to `dependencies`)
- Removed dependency [`uWebSockets.js@uNetworking/uWebSockets.js#semver:^20` ↗︎](https://www.npmjs.com/package/uWebSockets.js/v/20.0.0) (from `dependencies`)
6 changes: 6 additions & 0 deletions .changeset/forty-experts-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@graphql-mesh/utils": patch
"@graphql-mesh/serve-cli": patch
---

Fallback to node:http when uWebSockets.js is not available
3 changes: 0 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,6 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: 'yarn'

- name: Remove node-libcurl
run: node scripts/remove-node-libcurl.cjs

- uses: the-guild-org/shared-config/setup@main
name: setup env
with:
Expand Down
65 changes: 65 additions & 0 deletions examples/json-schema-example/tests/artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { createServer } from 'http';
import { AddressInfo } from 'net';
import { join } from 'path';
import { DEFAULT_CLI_PARAMS, serveMesh } from '@graphql-mesh/cli';
import { fs } from '@graphql-mesh/cross-helpers';
import { Logger } from '@graphql-mesh/types';
import { fetch } from '@whatwg-node/fetch';
import { TerminateHandler } from '../../../packages/legacy/utils/dist/typings/registerTerminateHandler';

const { readFile } = fs.promises;

const getFreePort = () =>
new Promise<number>((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, () => {
const port = (server.address() as AddressInfo)?.port;
server.closeAllConnections();
server.close(err => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
});
});

describe('Artifacts', () => {
it('should execute queries', async () => {
const { getBuiltMesh } = await import('../.mesh/index');
Expand Down Expand Up @@ -32,4 +55,46 @@ describe('Artifacts', () => {
expect(sdkResult?.me?.company?.employers?.[0]?.firstName).toBeDefined();
expect(sdkResult?.me?.company?.employers?.[0]?.jobTitle).toBeDefined();
});
it('should fallback to node:http when uWebSockets.js is not available', async () => {
const terminateHandlers: TerminateHandler[] = [];
try {
const { getBuiltMesh } = await import('../.mesh/index');
jest.mock('uWebSockets.js', () => {
throw new Error('uWebSockets.js is not available');
});
const mockLogger: Logger = {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
log: jest.fn(),
child: jest.fn(() => mockLogger),
};
const PORT = await getFreePort();
await serveMesh(
{
baseDir: join(__dirname, '..'),
argsPort: PORT,
getBuiltMesh,
logger: mockLogger,
rawServeConfig: {
browser: false,
},
registerTerminateHandler(terminateHandler) {
terminateHandlers.push(terminateHandler);
},
},
DEFAULT_CLI_PARAMS,
);
expect(mockLogger.warn).toHaveBeenCalledWith(
'uWebSockets.js is not available currently so the server will fallback to node:http.',
);
const res = await fetch(`http://127.0.0.1:${PORT}/graphql`);
expect(res.status).toBe(200);
await res.text();
} finally {
jest.resetModules();
await Promise.all(terminateHandlers.map(terminateHandler => terminateHandler('SIGTERM')));
}
});
});
5 changes: 3 additions & 2 deletions packages/legacy/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@
"tsconfig-paths": "^4.2.0",
"tslib": "^2.4.0",
"typescript": "^5.4.2",
"uWebSockets.js": "uNetworking/uWebSockets.js#semver:^20",
"ws": "^8.17.0",
"yargs": "^17.7.1"
},
"optionalDependencies": {
"node-libcurl": "^4.0.0"
"node-libcurl": "^4.0.0",
"uWebSockets.js": "uNetworking/uWebSockets.js#semver:^20"
},
"devDependencies": {
"@types/lodash.get": "4.4.9",
Expand Down
37 changes: 37 additions & 0 deletions packages/legacy/cli/src/commands/serve/getGraphQLWSOpts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { execute, ExecutionArgs, subscribe } from 'graphql';
import { MeshInstance } from '@graphql-mesh/runtime';

export function getGraphQLWSOptions(getBuiltMesh: () => Promise<MeshInstance>) {
// yoga's envelop may augment the `execute` and `subscribe` operations
// so we need to make sure we always use the freshest instance
type EnvelopedExecutionArgs = ExecutionArgs & {
rootValue: {
execute: typeof execute;
subscribe: typeof subscribe;
};
};
return {
execute: args => (args as EnvelopedExecutionArgs).rootValue.execute(args),
subscribe: args => (args as EnvelopedExecutionArgs).rootValue.subscribe(args),
onSubscribe: async (ctx, msg) => {
const { getEnveloped } = await getBuiltMesh();
const { schema, execute, subscribe, contextFactory, parse, validate } = getEnveloped(ctx);

const args: EnvelopedExecutionArgs = {
schema,
operationName: msg.payload.operationName,
document: parse(msg.payload.query),
variableValues: msg.payload.variables,
contextValue: await contextFactory(),
rootValue: {
execute,
subscribe,
},
};

const errors = validate(args.schema, args.document);
if (errors.length) return errors;
return args;
},
};
}
52 changes: 52 additions & 0 deletions packages/legacy/cli/src/commands/serve/node-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* eslint-disable import/no-nodejs-modules */
import { promises as fsPromises } from 'fs';
import type { Server as HttpServer } from 'http';
import type { Server as HttpsServer } from 'https';
import { useServer } from 'graphql-ws/lib/use/ws';
import { getGraphQLWSOptions } from './getGraphQLWSOpts.js';
import type { ServerStartOptions, ServerStartResult } from './types.js';

export async function startNodeHttpServer({
meshHTTPHandler,
getBuiltMesh,
sslCredentials,
graphqlPath,
hostname,
port,
}: ServerStartOptions): Promise<ServerStartResult> {
let server: HttpServer | HttpsServer;
if (sslCredentials) {
const [key, cert] = await Promise.all([
fsPromises.readFile(sslCredentials.key),
fsPromises.readFile(sslCredentials.cert),
]);
const nodeHttps = await import('https');
server = nodeHttps.createServer(
{
key,
cert,
},
meshHTTPHandler,
);
} else {
const nodeHttp = await import('http');
server = nodeHttp.createServer(meshHTTPHandler);
}
const ws = await import('ws');
const wsServer = new ws.WebSocketServer({
path: graphqlPath,
server,
});
useServer(getGraphQLWSOptions(getBuiltMesh), wsServer);
return new Promise((resolve, reject) => {
server.once('error', err => reject(err));
server.listen(port, hostname, () => {
resolve({
stop: () => {
server.closeAllConnections();
server.close();
},
});
});
});
}
104 changes: 34 additions & 70 deletions packages/legacy/cli/src/commands/serve/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@
/* eslint-disable dot-notation */
import cluster from 'cluster';
import os from 'os';
import { execute, ExecutionArgs, subscribe } from 'graphql';
import { makeBehavior } from 'graphql-ws/lib/use/uWebSockets';
import open from 'open';
import type { TemplatedApp } from 'uWebSockets.js';
import { process } from '@graphql-mesh/cross-helpers';
import { createMeshHTTPHandler } from '@graphql-mesh/http';
import { MeshInstance, ServeMeshOptions } from '@graphql-mesh/runtime';
import { ServeMeshOptions } from '@graphql-mesh/runtime';
import type { Logger } from '@graphql-mesh/types';
import { registerTerminateHandler } from '@graphql-mesh/utils';
import { handleFatalError } from '../../handleFatalError.js';
import { TerminateHandler } from '@graphql-mesh/utils';
import { GraphQLMeshCLIParams } from '../../index.js';
import { startNodeHttpServer } from './node-http.js';
import { startuWebSocketsServer } from './uWebsockets.js';

function portSelectorFn(sources: [number, number, number], logger: Logger) {
const port = sources.find(source => Boolean(source)) || 4000;
Expand Down Expand Up @@ -42,6 +40,7 @@ export async function serveMesh(
logger,
rawServeConfig = {},
playgroundTitle,
registerTerminateHandler,
}: ServeMeshOptions,
cliParams: GraphQLMeshCLIParams,
) {
Expand Down Expand Up @@ -134,82 +133,47 @@ export async function serveMesh(
.catch(e => eventLogger.error(e));
});

let uWebSocketsApp: TemplatedApp;

const meshHTTPHandler = createMeshHTTPHandler({
baseDir,
getBuiltMesh,
rawServeConfig,
playgroundTitle,
});

if (sslCredentials) {
const { SSLApp } = await import('uWebSockets.js');
uWebSocketsApp = SSLApp({
key_file_name: sslCredentials.key,
cert_file_name: sslCredentials.cert,
});
} else {
const { App } = await import('uWebSockets.js');
uWebSocketsApp = App();
let uWebSocketsAvailable = false;
try {
await import('uWebSockets.js');
uWebSocketsAvailable = true;
} catch (err) {
logger.warn(
'uWebSockets.js is not available currently so the server will fallback to node:http.',
);
}

uWebSocketsApp.any('/*', meshHTTPHandler);

// yoga's envelop may augment the `execute` and `subscribe` operations
// so we need to make sure we always use the freshest instance
type EnvelopedExecutionArgs = ExecutionArgs & {
rootValue: {
execute: typeof execute;
subscribe: typeof subscribe;
};
};

const wsHandler = makeBehavior({
execute: args => (args as EnvelopedExecutionArgs).rootValue.execute(args),
subscribe: args => (args as EnvelopedExecutionArgs).rootValue.subscribe(args),
onSubscribe: async (ctx, msg) => {
const { getEnveloped } = await getBuiltMesh();
const { schema, execute, subscribe, contextFactory, parse, validate } = getEnveloped(ctx);

const args: EnvelopedExecutionArgs = {
schema,
operationName: msg.payload.operationName,
document: parse(msg.payload.query),
variableValues: msg.payload.variables,
contextValue: await contextFactory(),
rootValue: {
execute,
subscribe,
},
};

const errors = validate(args.schema, args.document);
if (errors.length) return errors;
return args;
},
const startServer = uWebSocketsAvailable ? startuWebSocketsServer : startNodeHttpServer;
const { stop } = await startServer({
meshHTTPHandler,
getBuiltMesh,
sslCredentials,
graphqlPath,
hostname,
port,
});

uWebSocketsApp.ws(graphqlPath, wsHandler);

uWebSocketsApp.listen(hostname, port, listenSocket => {
if (!listenSocket) {
logger.error(`Failed to listen to ${serverUrl}`);
process.exit(1);
}
registerTerminateHandler(async eventName => {
const eventLogger = logger.child(`${eventName} 💀`);
eventLogger.debug(`Stopping HTTP Server`);
uWebSocketsApp?.close?.();
eventLogger.debug(`HTTP Server has been stopped`);
});
if (browser) {
open(
serverUrl.replace('0.0.0.0', 'localhost'),
typeof browser === 'string' ? { app: browser } : undefined,
).catch(() => {});
}
registerTerminateHandler(async eventName => {
const eventLogger = logger.child(`${eventName} 💀`);
eventLogger.debug(`Stopping HTTP Server`);
stop();
eventLogger.debug(`HTTP Server has been stopped`);
});
if (browser) {
open(
serverUrl.replace('0.0.0.0', 'localhost'),
typeof browser === 'string' ? { app: browser } : undefined,
).catch(() => {
logger.warn(`Failed to open browser for ${serverUrl}`);
});
}
}
return null;
}
16 changes: 16 additions & 0 deletions packages/legacy/cli/src/commands/serve/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { MeshHTTPHandler } from '@graphql-mesh/http';
import type { MeshInstance } from '@graphql-mesh/runtime';
import type { YamlConfig } from '@graphql-mesh/types';

export interface ServerStartOptions {
meshHTTPHandler: MeshHTTPHandler;
getBuiltMesh: () => Promise<MeshInstance>;
sslCredentials: YamlConfig.ServeConfig['sslCredentials'];
graphqlPath: string;
hostname: string;
port: number;
}

export interface ServerStartResult {
stop: VoidFunction;
}
Loading

0 comments on commit c4d2249

Please sign in to comment.