@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.
@@ -0,0 +1,77 @@
1
+ import { execute, subscribe, validate, type GraphQLSchema, type ExecutionResult, type DocumentNode } from 'graphql';
2
+ import type { Gateway, Subgraph, Resolvers, GraphQLContext, ExecuteParams } from '../types.js';
3
+ import { createFieldResolver, createSubscribeFieldResolver } from './fieldResolver.js';
4
+ import { buildPublicSchema } from './publicSchema.js';
5
+
6
+ /**
7
+ * Create an in-process gateway over one or more subgraphs.
8
+ *
9
+ * Single-subgraph mode (Phase 5): the gateway exposes the subgraph's public
10
+ * schema and executes operations directly against the subgraph's resolvers —
11
+ * no network hop, shared in-process context (DB pool, services). This is the
12
+ * default app shape; federation overhead only kicks in when there are multiple
13
+ * subgraphs.
14
+ *
15
+ * Multi-subgraph composition + query planning is Phase 6; calling with >1
16
+ * subgraph here throws a clear "not yet" error so the API is forward-compatible.
17
+ */
18
+ export function createGateway(input: { subgraph: Subgraph } | { subgraphs: Subgraph[] }): Gateway {
19
+ const subgraphs: Subgraph[] = 'subgraph' in input ? [input.subgraph] : input.subgraphs;
20
+ if (subgraphs.length === 0) throw new Error('[nexus-graphql] createGateway requires at least one subgraph');
21
+ if (subgraphs.length > 1) {
22
+ throw new Error(
23
+ '[nexus-graphql] Multi-subgraph federation (composition + query planning) is implemented in Phase 6. ' +
24
+ 'Pass a single subgraph here, or use the Phase 6 composeSupergraph/createFederatedGateway.',
25
+ );
26
+ }
27
+
28
+ const only = subgraphs[0]!;
29
+ const resolvers: Resolvers = only.resolvers;
30
+ const schema: GraphQLSchema = buildPublicSchema(only.sdl);
31
+ const fieldResolver = createFieldResolver(resolvers);
32
+ const subscribeFieldResolver = createSubscribeFieldResolver(resolvers);
33
+
34
+ async function run(document: DocumentNode, params: Omit<ExecuteParams, 'schema' | 'resolvers' | 'document'>) {
35
+ const errors = validate(schema, document);
36
+ if (errors.length) return ({ errors: errors.map((e) => ({ message: e.message })) } as unknown) as ExecutionResult;
37
+ return execute({
38
+ schema,
39
+ document,
40
+ rootValue: params.rootValue,
41
+ contextValue: params.contextValue as GraphQLContext,
42
+ variableValues: params.variableValues,
43
+ operationName: params.operationName,
44
+ fieldResolver,
45
+ });
46
+ }
47
+
48
+ return {
49
+ schema,
50
+ subgraphs,
51
+ async execute(params) {
52
+ return run(params.document, params);
53
+ },
54
+ async subscribe(params) {
55
+ const document = params.document;
56
+ const errors = validate(schema, document);
57
+ if (errors.length) {
58
+ return (async function* () { yield { errors: errors.map((e) => ({ message: e.message })) }; })() as AsyncIterable<ExecutionResult>;
59
+ }
60
+ const result = await subscribe({
61
+ schema,
62
+ document,
63
+ rootValue: params.rootValue,
64
+ contextValue: params.contextValue as GraphQLContext,
65
+ variableValues: params.variableValues,
66
+ operationName: params.operationName,
67
+ subscribeFieldResolver,
68
+ fieldResolver,
69
+ });
70
+ // graphql-js returns an AsyncIterable on success or an ExecutionResult (with errors) otherwise.
71
+ if (Symbol.asyncIterator in (result as any)) {
72
+ return result as AsyncIterable<ExecutionResult>;
73
+ }
74
+ return (async function* () { yield result as ExecutionResult; })() as AsyncIterable<ExecutionResult>;
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,45 @@
1
+ import type { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql';
2
+ import type { Resolvers, GraphQLContext, SubscribeResolver } from '../types.js';
3
+
4
+ /**
5
+ * Build a graphql-js `fieldResolver` that dispatches to the user's resolvers
6
+ * map. Resolution order:
7
+ * 1. `__typename` → the parent's `__typename` (or the field's parent type name).
8
+ * 2. `resolvers[typeName][fieldName]` — if it's a `{ resolve, subscribe }`
9
+ * shape (subscription field), use `.resolve`; otherwise call it directly.
10
+ * 3. Default property access: `parent[fieldName]`.
11
+ */
12
+ export function createFieldResolver(resolvers: Resolvers): GraphQLFieldResolver<any, GraphQLContext> {
13
+ return (parent, args, context, info: GraphQLResolveInfo) => {
14
+ const fieldName = info.fieldName;
15
+ if (fieldName === '__typename') {
16
+ return parent?.__typename ?? info.parentType.name;
17
+ }
18
+ const typeResolvers = resolvers[info.parentType.name];
19
+ if (typeResolvers) {
20
+ const field = typeResolvers[fieldName];
21
+ if (field) {
22
+ if (typeof field === 'function') return field(parent, args, context, info);
23
+ if (typeof field === 'object' && field && typeof field.resolve === 'function') {
24
+ return field.resolve(parent, args, context, info);
25
+ }
26
+ }
27
+ }
28
+ // Default: read the property off the parent (works for DB docs/objects).
29
+ return parent != null ? parent[fieldName] : undefined;
30
+ };
31
+ }
32
+
33
+ /** Build a `subscribeFieldResolver` for subscription root fields. */
34
+ export function createSubscribeFieldResolver(resolvers: Resolvers): GraphQLFieldResolver<any, GraphQLContext> {
35
+ return (parent, args, context, info: GraphQLResolveInfo): AsyncIterable<any> | Promise<AsyncIterable<any>> => {
36
+ const typeResolvers = resolvers[info.parentType.name];
37
+ const field = typeResolvers?.[info.fieldName];
38
+ const subscribe: SubscribeResolver | undefined =
39
+ field && typeof field === 'object' && field ? field.subscribe : undefined;
40
+ if (typeof subscribe !== 'function') {
41
+ throw new Error(`No 'subscribe' resolver for ${info.parentType.name}.${info.fieldName}`);
42
+ }
43
+ return subscribe(parent, args, context, info);
44
+ };
45
+ }
@@ -0,0 +1,90 @@
1
+ import { parse, type DocumentNode } from 'graphql';
2
+ import type { RequestContext, Handler } from '@bhooai/nexus-core';
3
+ import type { Gateway, GraphQLContext } from '../types.js';
4
+
5
+ export interface GraphqlHandlerOptions {
6
+ /** The gateway to execute against. */
7
+ gateway: Gateway;
8
+ /** Build the per-request resolver context. Defaults to `{ request: ctx, user: ctx.state.user }`. */
9
+ context?: (ctx: RequestContext) => GraphQLContext | Promise<GraphQLContext>;
10
+ /** Allow introspection queries (default follows config.graphql.introspection = true). */
11
+ introspection?: boolean;
12
+ }
13
+
14
+ interface GraphQLRequestBody {
15
+ query?: string;
16
+ variables?: Record<string, any>;
17
+ operationName?: string;
18
+ }
19
+
20
+ /** True when a parsed operation set contains an introspection field (`__schema`/`__type`). */
21
+ function isIntrospectionDocument(doc: DocumentNode): boolean {
22
+ for (const def of doc.definitions) {
23
+ if (def.kind === 'OperationDefinition') {
24
+ const sels = def.selectionSet.selections;
25
+ for (const s of sels) {
26
+ if (s.kind === 'Field' && (s.name.value === '__schema' || s.name.value === '__type')) return true;
27
+ }
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+
33
+ /**
34
+ * HTTP handler for `/graphql`. Accepts POST with `{ query, variables, operationName }`
35
+ * (or GET with `?query=`). Applies the gateway in-process. Introspection is
36
+ * rejected when disabled.
37
+ */
38
+ export function graphqlHttpHandler(options: GraphqlHandlerOptions): Handler {
39
+ const { gateway, introspection = true } = options;
40
+ return async (ctx: RequestContext) => {
41
+ let body: GraphQLRequestBody;
42
+ if (ctx.method === 'GET') {
43
+ const q = ctx.query.query;
44
+ body = { query: Array.isArray(q) ? q[0] : q, variables: parseJsonQuery(ctx.query.variables), operationName: single(ctx.query.operationName) };
45
+ } else {
46
+ body = (ctx.body as GraphQLRequestBody) ?? {};
47
+ }
48
+
49
+ if (!body.query) {
50
+ ctx.json({ errors: [{ message: 'Must provide a query string.' }] }, 400);
51
+ return;
52
+ }
53
+
54
+ let document: DocumentNode;
55
+ try {
56
+ document = parse(body.query);
57
+ } catch (err) {
58
+ ctx.json({ errors: [{ message: (err as Error).message }] }, 400);
59
+ return;
60
+ }
61
+
62
+ if (!introspection && isIntrospectionDocument(document)) {
63
+ ctx.json({ errors: [{ message: 'Introspection is disabled.' }] }, 403);
64
+ return;
65
+ }
66
+
67
+ const contextValue = options.context
68
+ ? await options.context(ctx)
69
+ : { request: ctx, user: (ctx.state.user as GraphQLContext['user']) };
70
+
71
+ const result = await gateway.execute({
72
+ document,
73
+ variableValues: body.variables,
74
+ operationName: body.operationName,
75
+ contextValue,
76
+ });
77
+ ctx.json(result);
78
+ };
79
+ }
80
+
81
+ function parseJsonQuery(v: string | string[] | undefined): Record<string, any> | undefined {
82
+ if (v === undefined) return undefined;
83
+ const s = Array.isArray(v) ? v[0] : v;
84
+ if (!s) return undefined;
85
+ try { return JSON.parse(s as string); } catch { return undefined; }
86
+ }
87
+ function single(v: string | string[] | undefined): string | undefined {
88
+ if (v === undefined) return undefined;
89
+ return Array.isArray(v) ? v[0] : v;
90
+ }
@@ -0,0 +1,26 @@
1
+ import { buildSchema, GraphQLSchema } from 'graphql';
2
+
3
+ /**
4
+ * Strip federation directives from subgraph SDL to produce the *public*
5
+ * (client-facing) schema. The gateway exposes this clean schema; the federation
6
+ * internals (`_service`, `_entities`, `@key`, `@external`, ...) are not part of
7
+ * the public API. For the single-subgraph case the public schema is simply the
8
+ * user's types without the federation markers.
9
+ */
10
+ export function buildPublicSchema(subgraphSdl: string): GraphQLSchema {
11
+ const cleaned = subgraphSdl
12
+ // Drop the `extend schema @link(...)` clause and any standalone @link.
13
+ .replace(/extend\s+schema\s*@\s*link\s*\([^)]*\)/g, '')
14
+ .replace(/@\s*link\s*\([^)]*\)/g, '')
15
+ // Drop federation field/type directives.
16
+ .replace(/@\s*key\s*\([^)]*\)/g, '')
17
+ .replace(/@\s*requires\s*\([^)]*\)/g, '')
18
+ .replace(/@\s*provides\s*\([^)]*\)/g, '')
19
+ .replace(/@\s*external\b/g, '')
20
+ .replace(/@\s*extends\b/g, '')
21
+ // Collapse multiple blank lines left behind.
22
+ .replace(/\n{3,}/g, '\n\n')
23
+ .trim();
24
+
25
+ return buildSchema(cleaned);
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from './types.js';
2
+ export * from './subgraph/federationDirectives.js';
3
+ export * from './subgraph/parseKeys.js';
4
+ export * from './subgraph/defineSubgraph.js';
5
+ export * from './gateway/fieldResolver.js';
6
+ export * from './gateway/publicSchema.js';
7
+ export * from './gateway/createGateway.js';
8
+ export * from './gateway/graphqlHttpHandler.js';
9
+ export * from './subscriptions/PubSub.js';
10
+ export * from './subscriptions/SubscriptionServer.js';
11
+ export * from './federation/parseMetadata.js';
12
+ export * from './federation/composeSupergraph.js';
13
+ export * from './federation/executor.js';
14
+ export * from './federation/createFederatedGateway.js';
@@ -0,0 +1,115 @@
1
+ import { buildSchema, parse, validate, GraphQLSchema } from 'graphql';
2
+ import type { DefineSubgraphOptions, Subgraph, Resolvers, GraphQLContext, EntityRepresentation, EntityResolver } from '../types.js';
3
+ import { FEDERATION_DIRECTIVE_SDL, FEDERATION_LINK, FEDERATION_TYPES, entityUnionSdl } from './federationDirectives.js';
4
+ import { parseEntityKeys } from './parseKeys.js';
5
+ import { createFieldResolver } from '../gateway/fieldResolver.js';
6
+
7
+ const DEFAULT_ENTITY_RESOLVER: EntityResolver = (rep) => rep;
8
+
9
+ /**
10
+ * Define a federation-compliant subgraph from user typeDefs + resolvers.
11
+ *
12
+ * The user SDL may use federation directives (`@key`, `@external`, `@requires`,
13
+ * `@provides`, `@extends`). We prepend the directive definitions + the `@link`
14
+ * clause and append the federation boilerplate (`_Any`, `_Entity`, `_Service`,
15
+ * `Query._service`, `Query._entities`) so the resulting schema is a valid
16
+ * subgraph: `_service.sdl` returns the subgraph SDL and `_entities` resolves
17
+ * representations via the provided `entityResolver` (default: pass-through).
18
+ *
19
+ * `defineSubgraph` does NOT compose — it produces a single executable subgraph.
20
+ * The gateway (`createGateway`) executes it in-process for the single-subgraph
21
+ * case; Phase 6 composes multiple subgraphs.
22
+ */
23
+ export function defineSubgraph(options: DefineSubgraphOptions): Subgraph {
24
+ const { name, typeDefs, resolvers = {}, entityResolver = DEFAULT_ENTITY_RESOLVER } = options;
25
+
26
+ const entityKeys = parseEntityKeys(typeDefs);
27
+ const entityUnion = entityUnionSdl([...entityKeys.keys()]);
28
+
29
+ // Subgraph SDL = what _service.sdl returns: the user SDL + the @link clause.
30
+ // (We keep the federation directives in the published SDL — that's the point.)
31
+ const sdl = `${FEDERATION_LINK}\n\n${typeDefs.trim()}\n`;
32
+
33
+ // Executable schema: directive defs + @link + user SDL + federation types + _Entity union.
34
+ const fullSdl = [
35
+ FEDERATION_DIRECTIVE_SDL,
36
+ FEDERATION_LINK,
37
+ typeDefs,
38
+ FEDERATION_TYPES,
39
+ entityUnion,
40
+ ].join('\n\n');
41
+
42
+ let schema: GraphQLSchema;
43
+ try {
44
+ schema = buildSchema(fullSdl, { assumeValid: false });
45
+ } catch (err) {
46
+ throw new Error(`[nexus-graphql] Failed to build subgraph schema for "${name}": ${(err as Error).message}`);
47
+ }
48
+
49
+ // The `_Entity` union needs a resolveType so graphql-js can pick the concrete
50
+ // member type for each resolved entity. Entities carry `__typename` from
51
+ // their representation; we also backfill it onto results that lack it.
52
+ const entityType = schema.getType('_Entity') as
53
+ | { resolveType?: (value: any) => string | null }
54
+ | undefined;
55
+ if (entityType && 'resolveType' in entityType) {
56
+ entityType.resolveType = (value: any) => (value && typeof value === 'object' ? (value as any).__typename ?? null : null);
57
+ }
58
+
59
+ // Auto-wire the federation fields into the resolver map (merged, not mutated).
60
+ const federationResolvers: Resolvers = {
61
+ ...resolvers,
62
+ _Any: {
63
+ // Custom scalar: pass-through serialize/parse.
64
+ __serialize: (value: unknown) => value,
65
+ __parseValue: (value: unknown) => value,
66
+ __parseLiteral: (value: unknown) => value,
67
+ } as unknown as Resolvers['_Any'],
68
+ Query: {
69
+ ...(resolvers.Query ?? {}),
70
+ _service: () => ({ sdl }),
71
+ _entities: (_parent: unknown, args: Record<string, any>, ctx: GraphQLContext) =>
72
+ Promise.all(((args.representations as EntityRepresentation[] | undefined) ?? []).map(async (rep) => {
73
+ const resolved = await entityResolver(rep, ctx);
74
+ if (resolved && typeof resolved === 'object' && !('__typename' in resolved)) {
75
+ (resolved as Record<string, unknown>).__typename = rep.__typename;
76
+ }
77
+ return resolved;
78
+ })),
79
+ },
80
+ _Service: {
81
+ sdl: (parent: { sdl?: string }) => parent.sdl ?? sdl,
82
+ },
83
+ };
84
+
85
+ // Validate the SDL parses + that the federation directives are used correctly
86
+ // (e.g. @key has a `fields` arg). buildSchema already enforces directive
87
+ // argument types/locations; we additionally assert the SDL parses standalone.
88
+ parse(sdl);
89
+
90
+ return {
91
+ name,
92
+ schema,
93
+ sdl,
94
+ entityKeys,
95
+ resolvers: federationResolvers,
96
+ entityResolver,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Validate a query against a schema before execution (returns errors array).
102
+ * Useful for the HTTP handler to reject malformed operations early.
103
+ */
104
+ export function validateOperation(schema: GraphQLSchema, source: string) {
105
+ let doc;
106
+ try {
107
+ doc = parse(source);
108
+ } catch (err) {
109
+ return [(err as Error).message];
110
+ }
111
+ return validate(schema, doc).map((e) => e.message);
112
+ }
113
+
114
+ /** Re-export for the gateway to share the resolver wiring. */
115
+ export { createFieldResolver };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Federation directive definitions and the `@link` schema directive.
3
+ *
4
+ * These are prepended to the user's typeDefs so `buildSchema` accepts the
5
+ * federation directives (`@key`, `@external`, `@requires`, `@provides`,
6
+ * `@extends`). We target a Federation v2-style subset (the same directives
7
+ * Apollo uses); composition (Phase 6) is ours, but the directive *surface*
8
+ * matches the spec so subgraph SDL is interoperable.
9
+ *
10
+ * NOTE: `@link` is declared so `extend schema @link(...)` parses. We do not
11
+ * enforce the import list — the directives are always declared and available.
12
+ */
13
+ export const FEDERATION_DIRECTIVE_SDL = /* graphql */ `
14
+ directive @link(url: String!, import: [String!]) on SCHEMA
15
+ directive @key(fields: String!) repeatable on OBJECT | INTERFACE
16
+ directive @external on FIELD_DEFINITION
17
+ directive @requires(fields: String!) on FIELD_DEFINITION
18
+ directive @provides(fields: String!) on FIELD_DEFINITION
19
+ directive @extends on OBJECT | INTERFACE | FIELD_DEFINITION
20
+ `;
21
+
22
+ /** The `extend schema @link(...)` clause advertising the federation import. */
23
+ export const FEDERATION_LINK = /* graphql */ `
24
+ extend schema @link(
25
+ url: "https://specs.apollo.dev/federation/v2.0"
26
+ import: ["@key", "@external", "@requires", "@provides", "@extends"]
27
+ )
28
+ `;
29
+
30
+ /** Federation boilerplate types prepended/appended to make a schema subgraph-compliant. */
31
+ export const FEDERATION_TYPES = /* graphql */ `
32
+ scalar _Any
33
+ type _Service { sdl: String! }
34
+ extend type Query {
35
+ _service: _Service!
36
+ _entities(representations: [_Any!]!): [_Entity]!
37
+ }
38
+ `;
39
+
40
+ /** Build the `_Entity` union SDL for the given entity type names (or a scalar if none). */
41
+ export function entityUnionSdl(entityTypeNames: string[]): string {
42
+ if (entityTypeNames.length === 0) return `scalar _Entity`;
43
+ return `union _Entity = ${entityTypeNames.join(' | ')}`;
44
+ }
45
+
46
+ /** Names of the federation directives we recognize (for stripping when building the public schema). */
47
+ export const FEDERATION_DIRECTIVE_NAMES = ['key', 'external', 'requires', 'provides', 'extends', 'link'];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Parse `@key(fields: "...")` directives from subgraph SDL to learn which types
3
+ * are entities and what their key field paths are. This is a pragmatic regex scan
4
+ * (not a full SDL parser) — sufficient for the v1 federation subset and for the
5
+ * composition step in Phase 6. Composite keys like `@key(fields: "id tenant")` are
6
+ * supported (space-separated, as in the spec).
7
+ */
8
+ export function parseEntityKeys(typeDefs: string): Map<string, string[]> {
9
+ const keys = new Map<string, string[]>();
10
+ // Match `type X ... {` or `extend type X ... {` (and interfaces) up to the body.
11
+ const typeHeader = /(?:extend\s+)?(?:type|interface)\s+(\w+)([^{]*)\{/g;
12
+ let m: RegExpExecArray | null;
13
+ while ((m = typeHeader.exec(typeDefs)) !== null) {
14
+ const name = m[1]!;
15
+ const header = m[2]!;
16
+ const keyFields: string[] = [];
17
+ const keyRe = /@key\s*\(\s*fields:\s*"([^"]+)"\s*\)/g;
18
+ let k: RegExpExecArray | null;
19
+ while ((k = keyRe.exec(header)) !== null) {
20
+ keyFields.push(...k[1]!.split(/\s+/).filter(Boolean));
21
+ }
22
+ if (keyFields.length) keys.set(name, keyFields);
23
+ }
24
+ return keys;
25
+ }
@@ -0,0 +1,64 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ /**
4
+ * Minimal in-process PubSub for GraphQL subscriptions. Sufficient for the
5
+ * single-instance default app; a Redis-backed pub/sub adapter can be plugged in
6
+ * later for cross-instance fanout (mirrors the realtime adapter pattern).
7
+ *
8
+ * Usage in a subscription resolver:
9
+ * Subscription: {
10
+ * messageAdded: {
11
+ * subscribe: () => pubsub.asyncIterator('MESSAGE_ADDED'),
12
+ * resolve: (payload) => payload,
13
+ * }
14
+ * }
15
+ */
16
+ export class PubSub {
17
+ private ee = new EventEmitter();
18
+
19
+ constructor() {
20
+ this.ee.setMaxListeners(0);
21
+ }
22
+
23
+ /** Publish a payload to a topic. */
24
+ publish(topic: string, payload: unknown): void {
25
+ this.ee.emit(topic, payload);
26
+ }
27
+
28
+ /** Return an AsyncIterable that yields payloads published to the topic(s). */
29
+ asyncIterator(topics: string | string[]): AsyncIterable<any> {
30
+ const list = Array.isArray(topics) ? topics : [topics];
31
+ const ee = this.ee;
32
+ const queue: any[] = [];
33
+ let pull: ((v: IteratorResult<any>) => void) | undefined;
34
+
35
+ for (const t of list) {
36
+ const push = (payload: any) => {
37
+ if (pull) {
38
+ const resolve = pull;
39
+ pull = undefined;
40
+ resolve({ value: payload, done: false });
41
+ } else {
42
+ queue.push(payload);
43
+ }
44
+ };
45
+ ee.on(t, push);
46
+ }
47
+
48
+ return {
49
+ [Symbol.asyncIterator]() {
50
+ return {
51
+ next(): Promise<IteratorResult<any>> {
52
+ if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
53
+ return new Promise((resolve) => { pull = resolve; });
54
+ },
55
+ return(): Promise<IteratorResult<any>> {
56
+ // Unsubscribe listeners on early completion (client disconnect).
57
+ for (const t of list) ee.removeAllListeners(t);
58
+ return Promise.resolve({ value: undefined, done: true });
59
+ },
60
+ };
61
+ },
62
+ };
63
+ }
64
+ }