@bhooai/nexus-graphql 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/package.json +24 -0
- package/src/federation/composeSupergraph.ts +153 -0
- package/src/federation/createFederatedGateway.ts +36 -0
- package/src/federation/executor.ts +305 -0
- package/src/federation/parseMetadata.ts +87 -0
- package/src/gateway/createGateway.ts +77 -0
- package/src/gateway/fieldResolver.ts +45 -0
- package/src/gateway/graphqlHttpHandler.ts +90 -0
- package/src/gateway/publicSchema.ts +26 -0
- package/src/index.ts +14 -0
- package/src/subgraph/defineSubgraph.ts +115 -0
- package/src/subgraph/federationDirectives.ts +47 -0
- package/src/subgraph/parseKeys.ts +25 -0
- package/src/subscriptions/PubSub.ts +64 -0
- package/src/subscriptions/SubscriptionServer.ts +203 -0
- package/src/types.ts +77 -0
- package/tests/federation.test.ts +153 -0
- package/tests/graphql.test.ts +172 -0
- package/tests/subscriptions.test.ts +182 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { createServer, type Server as HttpServer } from 'node:http';
|
|
3
|
+
import type { AddressInfo } from 'node:net';
|
|
4
|
+
import { WebSocket, type RawData } from 'ws';
|
|
5
|
+
import { parse } from 'graphql';
|
|
6
|
+
import { Router, NexusServer, bodyParser } from '@bhooai/nexus-core';
|
|
7
|
+
import { defineSubgraph, createGateway, graphqlHttpHandler, SubscriptionServer, PubSub } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
const TYPEDEFS = /* graphql */ `
|
|
10
|
+
type Message { id: ID! text: String! }
|
|
11
|
+
type Query { hello: String! }
|
|
12
|
+
type Mutation { post(text: String!): Message! }
|
|
13
|
+
type Subscription { messageAdded: Message! }
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
function makeGraph(pubsub: PubSub) {
|
|
17
|
+
const sub = defineSubgraph({
|
|
18
|
+
name: 'chat',
|
|
19
|
+
typeDefs: TYPEDEFS,
|
|
20
|
+
resolvers: {
|
|
21
|
+
Query: { hello: () => 'world' },
|
|
22
|
+
Mutation: {
|
|
23
|
+
post: (_p: any, args: { text: string }) => {
|
|
24
|
+
const msg = { id: String(Math.floor(Math.random() * 1e6)), text: args.text };
|
|
25
|
+
pubsub.publish('MESSAGE_ADDED', msg);
|
|
26
|
+
return msg;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
Subscription: {
|
|
30
|
+
messageAdded: { subscribe: () => pubsub.asyncIterator('MESSAGE_ADDED'), resolve: (p: any) => p },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
return { sub, gateway: createGateway({ subgraph: sub }) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('graphql HTTP handler (POST /graphql)', () => {
|
|
38
|
+
let server: NexusServer;
|
|
39
|
+
afterEach(async () => { if (server) await server.close(); });
|
|
40
|
+
|
|
41
|
+
async function boot(): Promise<number> {
|
|
42
|
+
const { gateway } = makeGraph(new PubSub());
|
|
43
|
+
const router = new Router();
|
|
44
|
+
// Body parsing is handled by the server-level middleware pipeline; do NOT
|
|
45
|
+
// also add bodyParser as route middleware (it would re-read the consumed
|
|
46
|
+
// stream and hang).
|
|
47
|
+
router.post('/graphql', graphqlHttpHandler({ gateway }));
|
|
48
|
+
server = new NexusServer({ router, middleware: [bodyParser(1 << 20)] });
|
|
49
|
+
await server.listen(0, '127.0.0.1');
|
|
50
|
+
return (server.httpServer.address() as AddressInfo).port;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function gql(port: number, body: unknown): Promise<any> {
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'content-type': 'application/json' },
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
});
|
|
59
|
+
return res.json();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('executes a query', async () => {
|
|
63
|
+
const p = await boot();
|
|
64
|
+
const r = await gql(p, { query: '{ hello }' });
|
|
65
|
+
expect(r.data.hello).toBe('world');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('executes a mutation with variables', async () => {
|
|
69
|
+
const p = await boot();
|
|
70
|
+
const r = await gql(p, { query: 'mutation($t:String!){ post(text:$t){ id text } }', variables: { t: 'hi' } });
|
|
71
|
+
expect(r.data.post.text).toBe('hi');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns an error for a parse failure', async () => {
|
|
75
|
+
const p = await boot();
|
|
76
|
+
const r = await gql(p, { query: '{ hello' });
|
|
77
|
+
expect(r.errors?.[0]?.message).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects introspection when disabled', async () => {
|
|
81
|
+
const { gateway } = makeGraph(new PubSub());
|
|
82
|
+
const router = new Router();
|
|
83
|
+
router.post('/graphql', graphqlHttpHandler({ gateway, introspection: false }));
|
|
84
|
+
server = new NexusServer({ router, middleware: [bodyParser(1 << 20)] });
|
|
85
|
+
await server.listen(0, '127.0.0.1');
|
|
86
|
+
const port = (server.httpServer.address() as AddressInfo).port;
|
|
87
|
+
const r = await gql(port, { query: '{ __schema { queryType { name } } }' });
|
|
88
|
+
expect(r.errors?.[0]?.message).toMatch(/ntrospection/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('graphql subscriptions over WS (graphql-transport-ws)', () => {
|
|
93
|
+
let http: HttpServer;
|
|
94
|
+
let sub: SubscriptionServer;
|
|
95
|
+
let port: number;
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
await sub?.close();
|
|
98
|
+
if (http) await new Promise<void>((r) => http.close(() => r()));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
async function bootShared(requireAuth = false): Promise<{ gateway: ReturnType<typeof createGateway>; pubsub: PubSub }> {
|
|
102
|
+
const pubsub = new PubSub();
|
|
103
|
+
const { gateway } = makeGraph(pubsub);
|
|
104
|
+
http = createServer((_req, res) => res.end());
|
|
105
|
+
sub = new SubscriptionServer({ httpServer: http, gateway, requireAuth });
|
|
106
|
+
port = await new Promise<number>((resolve) => http.listen(0, '127.0.0.1', () => resolve((http.address() as AddressInfo).port)));
|
|
107
|
+
return { gateway, pubsub };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface Client { ws: WebSocket; recv(): Promise<any>; recvOrTimeout(ms?: number): Promise<any>; send(msg: unknown): void; close(): Promise<void>; }
|
|
111
|
+
function connect(port: number): Promise<Client> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/graphql/ws`, ['graphql-transport-ws']);
|
|
114
|
+
const queue: any[] = [];
|
|
115
|
+
ws.on('open', () => resolve(makeClient(ws, queue)));
|
|
116
|
+
ws.on('error', reject);
|
|
117
|
+
ws.on('message', (d: RawData) => queue.push(JSON.parse(d.toString())));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function makeClient(ws: WebSocket, queue: any[]): Client {
|
|
121
|
+
const recv = () => new Promise<any>((resolve) => { const tick = () => { if (queue.length) resolve(queue.shift()); else setTimeout(tick, 10); }; tick(); });
|
|
122
|
+
return {
|
|
123
|
+
ws,
|
|
124
|
+
recv,
|
|
125
|
+
recvOrTimeout(ms = 3000) {
|
|
126
|
+
return Promise.race([
|
|
127
|
+
recv(),
|
|
128
|
+
new Promise<any>((_, reject) => setTimeout(() => reject(new Error('recv timeout')), ms)),
|
|
129
|
+
]);
|
|
130
|
+
},
|
|
131
|
+
send(msg) { ws.send(JSON.stringify(msg)); },
|
|
132
|
+
close() {
|
|
133
|
+
// If the server already closed the socket, resolve immediately — the
|
|
134
|
+
// 'close' event already fired before we could attach a listener.
|
|
135
|
+
if (ws.readyState === ws.CLOSED) return Promise.resolve();
|
|
136
|
+
return new Promise<void>((r) => { ws.on('close', () => r()); ws.close(); });
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it('streams a subscription result when a mutation publishes', async () => {
|
|
142
|
+
const { gateway } = await bootShared();
|
|
143
|
+
const c = await connect(port);
|
|
144
|
+
c.send({ type: 'connection_init' });
|
|
145
|
+
expect((await c.recvOrTimeout()).type).toBe('connection_ack');
|
|
146
|
+
c.send({ type: 'subscribe', id: '1', payload: { query: 'subscription { messageAdded { id text } }' } });
|
|
147
|
+
|
|
148
|
+
// Give the server time to process `subscribe` and register the pubsub
|
|
149
|
+
// listener before we publish (EventEmitter drops events with no listener).
|
|
150
|
+
await new Promise<void>((r) => setTimeout(r, 150));
|
|
151
|
+
|
|
152
|
+
// Fire the mutation in-process via the gateway (wires the fieldResolver);
|
|
153
|
+
// the resolver publishes to the same shared pubsub the subscription listens on.
|
|
154
|
+
await gateway.execute({ document: parse(`mutation { post(text:"streamed"){ id text } }`), contextValue: {} });
|
|
155
|
+
|
|
156
|
+
const evt = await c.recvOrTimeout();
|
|
157
|
+
expect(evt.type).toBe('next');
|
|
158
|
+
expect(evt.payload.data.messageAdded.text).toBe('streamed');
|
|
159
|
+
await c.close();
|
|
160
|
+
}, 10_000);
|
|
161
|
+
|
|
162
|
+
it('replies pong to ping', async () => {
|
|
163
|
+
await bootShared();
|
|
164
|
+
const c = await connect(port);
|
|
165
|
+
c.send({ type: 'connection_init' });
|
|
166
|
+
await c.recvOrTimeout(); // ack
|
|
167
|
+
c.send({ type: 'ping' });
|
|
168
|
+
const evt = await c.recvOrTimeout();
|
|
169
|
+
expect(evt.type).toBe('pong');
|
|
170
|
+
await c.close();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects a subscription connection when auth is required and no token', async () => {
|
|
174
|
+
await bootShared(true);
|
|
175
|
+
const c = await connect(port);
|
|
176
|
+
c.send({ type: 'connection_init' });
|
|
177
|
+
// Server replies with an `error` frame then closes the socket.
|
|
178
|
+
const evt = await c.recvOrTimeout();
|
|
179
|
+
expect(evt.type).toBe('error');
|
|
180
|
+
await c.close();
|
|
181
|
+
});
|
|
182
|
+
});
|
package/tsconfig.json
ADDED