@btravstack/graphql-server 0.26.0 → 0.28.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 CHANGED
@@ -10,7 +10,12 @@ Pass `units` with `unit` to check each injected port against the module the
10
10
  selected request kind actually forks. Pass the same `units` record to
11
11
  `httpServer({ unit: units })` so the runtime can apply unit test overrides.
12
12
  Yoga plugins and CORS policy are explicit
13
- options; the wildcard CORS default and Yoga console logging are disabled.
13
+ options; the wildcard CORS default and Yoga console logging are disabled, and
14
+ a masked defect is reported to `Observers` instead. GraphiQL and
15
+ introspection stay off unless `GRAPHQL_DEVELOPER_TOOLS=true` (or
16
+ `developerTools: true`) turns both on. `fieldResult(result)`
17
+ answers a resolver from an `AsyncResult`, with a refusal on its own field's
18
+ path.
14
19
  Preflights use Yoga's CORS policy before authentication, and Yoga plugins are
15
20
  disposed when the application stops.
16
21
 
package/dist/index.cjs CHANGED
@@ -1,10 +1,43 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let graphql_yoga = require("graphql-yoga");
3
+ let _btravstack_config = require("@btravstack/config");
4
+ let _btravstack_core = require("@btravstack/core");
2
5
  let _btravstack_di = require("@btravstack/di");
3
6
  let _btravstack_http_server_internal = require("@btravstack/http-server/internal");
4
- let graphql_yoga = require("graphql-yoga");
5
7
  let unthrown = require("unthrown");
8
+ //#region src/field.ts
9
+ /**
10
+ * A resolver's answer from a `Result`, so a modeled failure is never thrown by
11
+ * hand. `Ok` is the field's value. `Err` is the `GraphQLError` the resolver's
12
+ * own triage minted from its `E` — an exhaustive `mapErrCases`, or
13
+ * `flatMapErrCases` where one refusal is `Ok(null)`, so widening `E` fails
14
+ * every resolver that folds it — and GraphQL reports it on this
15
+ * field's path, aliases and list indexes included, while its siblings resolve:
16
+ * its message and `extensions` reach the client, its `originalError` does not.
17
+ * A defect is masked to `Unexpected error.` whatever its cause, and `graphql()`
18
+ * reports it to `Observers`.
19
+ *
20
+ * **It answers a `Promise`, and that is the boundary**: a GraphQL resolver
21
+ * returns a value or a promise and reports a field's error by rejecting, so
22
+ * this is where a `Result` leaves for GraphQL's own channel — the one place a
23
+ * resolver needs, as `runMain` is for a process.
24
+ */
25
+ const fieldResult = async (result) => {
26
+ const settled = await result;
27
+ if (settled.isDefect()) throw new Error("fieldResult: the field's result was a defect", { cause: settled.cause });
28
+ if (settled.isErr()) throw (0, graphql_yoga.createGraphQLError)(settled.error.message, { extensions: settled.error.extensions });
29
+ return settled.value;
30
+ };
31
+ //#endregion
6
32
  //#region src/graphql.ts
7
33
  const dispose = Symbol("graphql.dispose");
34
+ /** What failed: GraphQL locates a resolver's error by wrapping it, and a log line walks `cause`, not `originalError`. */
35
+ const causeOf = (error) => typeof error === "object" && error !== null && "originalError" in error && error.originalError ? error.originalError : error;
36
+ const pathOf = (error) => typeof error === "object" && error !== null && "path" in error && Array.isArray(error.path) ? error.path.join(".") : void 0;
37
+ const noIntrospection = (context) => ({ Field: (node) => {
38
+ if (node.name.value === "__schema" || node.name.value === "__type") context.reportError((0, graphql_yoga.createGraphQLError)(`GraphQL introspection is not allowed, but the query contained ${node.name.value}`, { nodes: [node] }));
39
+ } });
40
+ const withoutIntrospection = { onValidate: ({ addValidationRule }) => addValidationRule(noIntrospection) };
8
41
  const graphql = (api, options) => {
9
42
  const mount = options.prefix ?? "/graphql";
10
43
  let end = mount.length;
@@ -12,15 +45,19 @@ const graphql = (api, options) => {
12
45
  const prefix = mount.slice(0, end);
13
46
  const requirements = options.requires;
14
47
  const schemes = [...new Set(requirements?.flatMap((requirement) => Object.keys(requirement)) ?? [])];
48
+ const developerToolsSchema = _btravstack_config.Config.object({ developerTools: _btravstack_config.Config.pinned(options.developerTools, _btravstack_config.Config.boolean("GRAPHQL_DEVELOPER_TOOLS", { default: false })) });
15
49
  const typed = _btravstack_di.Provider.member(_btravstack_http_server_internal.HttpHandler)({
16
50
  inject: {
51
+ env: _btravstack_config.Env,
17
52
  config: _btravstack_http_server_internal.HttpConfig,
18
53
  units: _btravstack_http_server_internal.HttpUnit,
54
+ observers: _btravstack_core.Observers,
19
55
  ...(0, _btravstack_http_server_internal.schemeDeps)(schemes)
20
56
  },
21
- sync: (services) => {
57
+ make: (services) => _btravstack_config.Config.parse("GraphqlConfig", developerToolsSchema)(services.env).map(({ developerTools }) => {
22
58
  const config = services.config;
23
59
  const units = services.units;
60
+ const observers = services.observers;
24
61
  if (options.units !== void 0) {
25
62
  for (const kind of requirements === void 0 ? ["anonymous"] : schemes) if ((options.units[kind] ?? options.units["anonymous"]) !== (units[kind] ?? units["anonymous"])) throw new Error(`[graphql-server] unit kind ${JSON.stringify(kind)} differs from HttpUnit`);
26
63
  }
@@ -29,11 +66,29 @@ const graphql = (api, options) => {
29
66
  schema: options.schema,
30
67
  graphqlEndpoint: prefix,
31
68
  cors: options.cors ?? (config.corsOrigin === "" ? false : { origin: config.corsOrigin.split(",").map((origin) => origin.trim()) }),
32
- plugins: [{ onRequest: ({ serverContext, fetchAPI, endResponse }) => {
33
- if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
34
- } }, ...options.plugins ?? []],
69
+ plugins: [
70
+ { onRequest: ({ serverContext, fetchAPI, endResponse }) => {
71
+ if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
72
+ } },
73
+ ...developerTools ? [] : [withoutIntrospection],
74
+ ...options.plugins ?? []
75
+ ],
76
+ maskedErrors: { maskError: (error, message, isDev) => {
77
+ const masked = (0, graphql_yoga.maskError)(error, message, isDev);
78
+ if (masked !== error) (0, _btravstack_core.observe)(observers, {
79
+ component: "graphql",
80
+ name: "defect",
81
+ attributes: {},
82
+ details: { "graphql.path": pathOf(error) },
83
+ traced: false
84
+ })({
85
+ outcome: "error",
86
+ cause: causeOf(error)
87
+ });
88
+ return masked;
89
+ } },
35
90
  logging: false,
36
- graphiql: false,
91
+ graphiql: developerTools,
37
92
  landingPage: false,
38
93
  multipart: false,
39
94
  maxRequestBodySize: config.bodyLimit === 0 ? false : config.bodyLimit,
@@ -78,10 +133,11 @@ const graphql = (api, options) => {
78
133
  },
79
134
  [dispose]: () => yoga.dispose()
80
135
  };
81
- },
136
+ }),
82
137
  onStop: (answerer) => answerer[dispose]()
83
138
  });
84
139
  return Object.assign(typed, { authenticators: api.providers });
85
140
  };
86
141
  //#endregion
142
+ exports.fieldResult = fieldResult;
87
143
  exports.graphql = graphql;
package/dist/index.d.cts CHANGED
@@ -1,25 +1,88 @@
1
+ import { GraphQLError, GraphQLSchema } from "graphql";
2
+ import { AsyncResult, Result } from "unthrown";
3
+ import { IncomingMessage } from "node:http";
4
+ import { ConfigInvalid, EnvReading } from "@btravstack/config";
1
5
  import { OneScheme, Requirements } from "@btravstack/contract";
6
+ import { Observers } from "@btravstack/core";
2
7
  import { AnyPort, Provider, Scope } from "@btravstack/di";
3
- import { Authenticators, HttpAuth, HttpConfig, HttpHandler, HttpUnit, KindOf, Kinds, RequiresGate, SchemePortsOf, UnitFor, UnitsNeedsOf, UnitsOf } from "@btravstack/http-server/internal";
4
- import { GraphQLSchema } from "graphql";
5
- import { Plugin, YogaServerOptions } from "graphql-yoga";
8
+ import { Authenticators, HttpAuth, HttpConfig, HttpHandler, HttpUnit, KindOf, Kinds, Principal, RequiresGate, SchemePortsOf, SchemesFrom, SchemesOf, UnitFor, UnitsNeedsOf, UnitsOf } from "@btravstack/http-server/internal";
9
+ import { Plugin, YogaInitialContext, YogaServerOptions } from "graphql-yoga";
10
+ //#region src/field.d.ts
11
+ /**
12
+ * A resolver's answer from a `Result`, so a modeled failure is never thrown by
13
+ * hand. `Ok` is the field's value. `Err` is the `GraphQLError` the resolver's
14
+ * own triage minted from its `E` — an exhaustive `mapErrCases`, or
15
+ * `flatMapErrCases` where one refusal is `Ok(null)`, so widening `E` fails
16
+ * every resolver that folds it — and GraphQL reports it on this
17
+ * field's path, aliases and list indexes included, while its siblings resolve:
18
+ * its message and `extensions` reach the client, its `originalError` does not.
19
+ * A defect is masked to `Unexpected error.` whatever its cause, and `graphql()`
20
+ * reports it to `Observers`.
21
+ *
22
+ * **It answers a `Promise`, and that is the boundary**: a GraphQL resolver
23
+ * returns a value or a promise and reports a field's error by rejecting, so
24
+ * this is where a `Result` leaves for GraphQL's own channel — the one place a
25
+ * resolver needs, as `runMain` is for a process.
26
+ */
27
+ export declare const fieldResult: <T>(result: AsyncResult<T, GraphQLError> | Result<T, GraphQLError>) => Promise<T>;
28
+ //#endregion
6
29
  //#region src/graphql.d.ts
7
30
  type YogaCors = YogaServerOptions<Record<string, unknown>, Record<string, unknown>>["cors"];
8
31
  type MissingUnitPorts<U extends Readonly<Record<string, AnyPort>>, Units, K extends string> = Exclude<keyof U, keyof UnitFor<U, Units, K>>;
9
32
  type UnitGate<U extends Readonly<Record<string, AnyPort>>, Units, K extends string> = [MissingUnitPorts<U, Units, K>] extends [never] ? unknown : {
10
33
  readonly "UNIT PORT MISSING — the selected kind does not export": MissingUnitPorts<U, Units, K>;
11
34
  };
35
+ /**
36
+ * The context `graphql()` hands Yoga for an operation: what a resolver and a
37
+ * plugin's operation hooks (`onParse`, `onValidate`, `onContextBuilding`,
38
+ * `onExecute`, `onSubscribe`) read beside Yoga's own `request`. `principal` is
39
+ * the caller `requires` resolved, `undefined` without `requires`; `unit` is the
40
+ * `unit` record read off the forked kind; `incoming` is the Node request.
41
+ *
42
+ * Those hooks run only once the caller is authenticated. A preflight and a
43
+ * refusal end in Yoga's server hooks (`onRequest`, `onResponse`), whose
44
+ * context is not this type, so a plugin cannot claim a principal there.
45
+ */
46
+ type GraphqlContext<Principal = unknown, Unit = Readonly<Record<string, unknown>>> = {
47
+ readonly principal: Principal;
48
+ readonly incoming: IncomingMessage;
49
+ readonly unit: Unit;
50
+ readonly signal: AbortSignal;
51
+ };
52
+ /** The {@link GraphqlContext} one `graphql()` call builds, from its own options. */
53
+ type ContextOf<A extends Authenticators, Units, R extends Requirements, U extends Readonly<Record<string, AnyPort>>> = GraphqlContext<[R] extends [never] ? undefined : Principal<SchemesOf<R>, SchemesFrom<A>>, UnitFor<U, Units, KindOf<R>>>;
54
+ /**
55
+ * Each plugin, unless the context this call builds lacks something it reads, or
56
+ * the entry is not a plugin at all.
57
+ * Per element, because Yoga's `Plugin<C>` is invariant in `C`: a plugin typed
58
+ * for the real context is not a `Plugin`, so none could be widened to one.
59
+ */
60
+ type PluginsGate<PS extends readonly unknown[], C> = { readonly [I in keyof PS]: PS[I] extends Plugin<infer X> ? YogaInitialContext & C extends X ? PS[I] : "PLUGIN CONTEXT MISMATCH — this plugin reads what this graphql() call does not put in its context" : {
61
+ readonly "NOT A PLUGIN — this entry is not a Yoga plugin": PS[I];
62
+ }; };
12
63
  /** Serve a GraphQL schema as one answerer under the existing HTTP runtime. */
13
- type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; }, U extends Readonly<Record<string, AnyPort>>> = {
64
+ type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; }, U extends Readonly<Record<string, AnyPort>>, PS extends readonly unknown[] = readonly Plugin[]> = {
14
65
  readonly schema: GraphQLSchema;
15
66
  readonly prefix?: `/${string}`;
16
67
  readonly cors?: YogaCors;
17
- readonly plugins?: readonly Plugin[];
68
+ /**
69
+ * GraphiQL and introspection, both or neither — pins
70
+ * `GRAPHQL_DEVELOPER_TOOLS`, which defaults to `false`, so a deployment
71
+ * exposes neither unless it says so.
72
+ */
73
+ readonly developerTools?: boolean;
74
+ /**
75
+ * Yoga plugins. Each may be typed by the context it reads — a
76
+ * `Plugin<{ unit: … }>` — and is checked against the {@link GraphqlContext}
77
+ * this call builds.
78
+ */
79
+ readonly plugins?: (PS & PluginsGate<PS, ContextOf<A, Units, R, U>>) | readonly Plugin<YogaInitialContext & ContextOf<A, Units, R, U>>[];
18
80
  readonly units?: Units & { readonly [K in Exclude<keyof Units, Kinds<A>>]: never; };
19
81
  readonly unit?: U & UnitGate<U, Units, KindOf<R>>;
20
82
  readonly requires?: R & RequiresGate<R, { readonly [K in keyof A]: A[K]["scope"]; }>;
21
83
  };
22
- export declare const graphql: <A extends Authenticators, Units extends UnitsOf<A> = Record<never, never>, const R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; } = never, U extends Readonly<Record<string, AnyPort>> = Record<never, never>>(api: HttpAuth<A>, options: GraphqlOptions<A, Units, R, U>) => Provider<HttpHandler, never, HttpConfig | HttpUnit | Scope | SchemePortsOf<R> | UnitsNeedsOf<Units>> & {
84
+ export declare const graphql: <A extends Authenticators, Units extends UnitsOf<A> = Record<never, never>, const R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; } = never, U extends Readonly<Record<string, AnyPort>> = Record<never, never>, const PS extends readonly unknown[] = []>(api: HttpAuth<A>, options: GraphqlOptions<A, Units, R, U, PS>) => Provider<HttpHandler, ConfigInvalid, HttpConfig | HttpUnit | Observers | Scope | SchemePortsOf<R> | UnitsNeedsOf<Units> | EnvReading<never, "GRAPHQL_DEVELOPER_TOOLS">> & {
23
85
  authenticators: readonly import("@btravstack/http-server/internal").SchemeProviders<A>[];
24
86
  };
25
- //#endregion
87
+ //#endregion
88
+ export type { GraphqlContext, GraphqlOptions };
package/dist/index.d.mts CHANGED
@@ -1,25 +1,88 @@
1
+ import { Plugin, YogaInitialContext, YogaServerOptions } from "graphql-yoga";
2
+ import { ConfigInvalid, EnvReading } from "@btravstack/config";
3
+ import { Observers } from "@btravstack/core";
1
4
  import { AnyPort, Provider, Scope } from "@btravstack/di";
2
- import { Authenticators, HttpAuth, HttpConfig, HttpHandler, HttpUnit, KindOf, Kinds, RequiresGate, SchemePortsOf, UnitFor, UnitsNeedsOf, UnitsOf } from "@btravstack/http-server/internal";
3
- import { Plugin, YogaServerOptions } from "graphql-yoga";
5
+ import { Authenticators, HttpAuth, HttpConfig, HttpHandler, HttpUnit, KindOf, Kinds, Principal, RequiresGate, SchemePortsOf, SchemesFrom, SchemesOf, UnitFor, UnitsNeedsOf, UnitsOf } from "@btravstack/http-server/internal";
6
+ import { AsyncResult, Result } from "unthrown";
7
+ import { GraphQLError, GraphQLSchema } from "graphql";
8
+ import { IncomingMessage } from "node:http";
4
9
  import { OneScheme, Requirements } from "@btravstack/contract";
5
- import { GraphQLSchema } from "graphql";
10
+ //#region src/field.d.ts
11
+ /**
12
+ * A resolver's answer from a `Result`, so a modeled failure is never thrown by
13
+ * hand. `Ok` is the field's value. `Err` is the `GraphQLError` the resolver's
14
+ * own triage minted from its `E` — an exhaustive `mapErrCases`, or
15
+ * `flatMapErrCases` where one refusal is `Ok(null)`, so widening `E` fails
16
+ * every resolver that folds it — and GraphQL reports it on this
17
+ * field's path, aliases and list indexes included, while its siblings resolve:
18
+ * its message and `extensions` reach the client, its `originalError` does not.
19
+ * A defect is masked to `Unexpected error.` whatever its cause, and `graphql()`
20
+ * reports it to `Observers`.
21
+ *
22
+ * **It answers a `Promise`, and that is the boundary**: a GraphQL resolver
23
+ * returns a value or a promise and reports a field's error by rejecting, so
24
+ * this is where a `Result` leaves for GraphQL's own channel — the one place a
25
+ * resolver needs, as `runMain` is for a process.
26
+ */
27
+ export declare const fieldResult: <T>(result: AsyncResult<T, GraphQLError> | Result<T, GraphQLError>) => Promise<T>;
28
+ //#endregion
6
29
  //#region src/graphql.d.ts
7
30
  type YogaCors = YogaServerOptions<Record<string, unknown>, Record<string, unknown>>["cors"];
8
31
  type MissingUnitPorts<U extends Readonly<Record<string, AnyPort>>, Units, K extends string> = Exclude<keyof U, keyof UnitFor<U, Units, K>>;
9
32
  type UnitGate<U extends Readonly<Record<string, AnyPort>>, Units, K extends string> = [MissingUnitPorts<U, Units, K>] extends [never] ? unknown : {
10
33
  readonly "UNIT PORT MISSING — the selected kind does not export": MissingUnitPorts<U, Units, K>;
11
34
  };
35
+ /**
36
+ * The context `graphql()` hands Yoga for an operation: what a resolver and a
37
+ * plugin's operation hooks (`onParse`, `onValidate`, `onContextBuilding`,
38
+ * `onExecute`, `onSubscribe`) read beside Yoga's own `request`. `principal` is
39
+ * the caller `requires` resolved, `undefined` without `requires`; `unit` is the
40
+ * `unit` record read off the forked kind; `incoming` is the Node request.
41
+ *
42
+ * Those hooks run only once the caller is authenticated. A preflight and a
43
+ * refusal end in Yoga's server hooks (`onRequest`, `onResponse`), whose
44
+ * context is not this type, so a plugin cannot claim a principal there.
45
+ */
46
+ type GraphqlContext<Principal = unknown, Unit = Readonly<Record<string, unknown>>> = {
47
+ readonly principal: Principal;
48
+ readonly incoming: IncomingMessage;
49
+ readonly unit: Unit;
50
+ readonly signal: AbortSignal;
51
+ };
52
+ /** The {@link GraphqlContext} one `graphql()` call builds, from its own options. */
53
+ type ContextOf<A extends Authenticators, Units, R extends Requirements, U extends Readonly<Record<string, AnyPort>>> = GraphqlContext<[R] extends [never] ? undefined : Principal<SchemesOf<R>, SchemesFrom<A>>, UnitFor<U, Units, KindOf<R>>>;
54
+ /**
55
+ * Each plugin, unless the context this call builds lacks something it reads, or
56
+ * the entry is not a plugin at all.
57
+ * Per element, because Yoga's `Plugin<C>` is invariant in `C`: a plugin typed
58
+ * for the real context is not a `Plugin`, so none could be widened to one.
59
+ */
60
+ type PluginsGate<PS extends readonly unknown[], C> = { readonly [I in keyof PS]: PS[I] extends Plugin<infer X> ? YogaInitialContext & C extends X ? PS[I] : "PLUGIN CONTEXT MISMATCH — this plugin reads what this graphql() call does not put in its context" : {
61
+ readonly "NOT A PLUGIN — this entry is not a Yoga plugin": PS[I];
62
+ }; };
12
63
  /** Serve a GraphQL schema as one answerer under the existing HTTP runtime. */
13
- type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; }, U extends Readonly<Record<string, AnyPort>>> = {
64
+ type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; }, U extends Readonly<Record<string, AnyPort>>, PS extends readonly unknown[] = readonly Plugin[]> = {
14
65
  readonly schema: GraphQLSchema;
15
66
  readonly prefix?: `/${string}`;
16
67
  readonly cors?: YogaCors;
17
- readonly plugins?: readonly Plugin[];
68
+ /**
69
+ * GraphiQL and introspection, both or neither — pins
70
+ * `GRAPHQL_DEVELOPER_TOOLS`, which defaults to `false`, so a deployment
71
+ * exposes neither unless it says so.
72
+ */
73
+ readonly developerTools?: boolean;
74
+ /**
75
+ * Yoga plugins. Each may be typed by the context it reads — a
76
+ * `Plugin<{ unit: … }>` — and is checked against the {@link GraphqlContext}
77
+ * this call builds.
78
+ */
79
+ readonly plugins?: (PS & PluginsGate<PS, ContextOf<A, Units, R, U>>) | readonly Plugin<YogaInitialContext & ContextOf<A, Units, R, U>>[];
18
80
  readonly units?: Units & { readonly [K in Exclude<keyof Units, Kinds<A>>]: never; };
19
81
  readonly unit?: U & UnitGate<U, Units, KindOf<R>>;
20
82
  readonly requires?: R & RequiresGate<R, { readonly [K in keyof A]: A[K]["scope"]; }>;
21
83
  };
22
- export declare const graphql: <A extends Authenticators, Units extends UnitsOf<A> = Record<never, never>, const R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; } = never, U extends Readonly<Record<string, AnyPort>> = Record<never, never>>(api: HttpAuth<A>, options: GraphqlOptions<A, Units, R, U>) => Provider<HttpHandler, never, HttpConfig | HttpUnit | Scope | SchemePortsOf<R> | UnitsNeedsOf<Units>> & {
84
+ export declare const graphql: <A extends Authenticators, Units extends UnitsOf<A> = Record<never, never>, const R extends Requirements & { readonly [I in keyof R]: OneScheme<R[I]>; } = never, U extends Readonly<Record<string, AnyPort>> = Record<never, never>, const PS extends readonly unknown[] = []>(api: HttpAuth<A>, options: GraphqlOptions<A, Units, R, U, PS>) => Provider<HttpHandler, ConfigInvalid, HttpConfig | HttpUnit | Observers | Scope | SchemePortsOf<R> | UnitsNeedsOf<Units> | EnvReading<never, "GRAPHQL_DEVELOPER_TOOLS">> & {
23
85
  authenticators: readonly import("@btravstack/http-server/internal").SchemeProviders<A>[];
24
86
  };
25
- //#endregion
87
+ //#endregion
88
+ export type { GraphqlContext, GraphqlOptions };
package/dist/index.mjs CHANGED
@@ -1,9 +1,42 @@
1
+ import { createGraphQLError, createYoga, maskError } from "graphql-yoga";
2
+ import { Config, Env } from "@btravstack/config";
3
+ import { Observers, observe } from "@btravstack/core";
1
4
  import { Provider } from "@btravstack/di";
2
5
  import { HttpConfig, HttpHandler, HttpUnit, forkUnit, principalOf, resolveScheme, schemeDeps, schemeServices } from "@btravstack/http-server/internal";
3
- import { createYoga } from "graphql-yoga";
4
6
  import { P } from "unthrown";
7
+ //#region src/field.ts
8
+ /**
9
+ * A resolver's answer from a `Result`, so a modeled failure is never thrown by
10
+ * hand. `Ok` is the field's value. `Err` is the `GraphQLError` the resolver's
11
+ * own triage minted from its `E` — an exhaustive `mapErrCases`, or
12
+ * `flatMapErrCases` where one refusal is `Ok(null)`, so widening `E` fails
13
+ * every resolver that folds it — and GraphQL reports it on this
14
+ * field's path, aliases and list indexes included, while its siblings resolve:
15
+ * its message and `extensions` reach the client, its `originalError` does not.
16
+ * A defect is masked to `Unexpected error.` whatever its cause, and `graphql()`
17
+ * reports it to `Observers`.
18
+ *
19
+ * **It answers a `Promise`, and that is the boundary**: a GraphQL resolver
20
+ * returns a value or a promise and reports a field's error by rejecting, so
21
+ * this is where a `Result` leaves for GraphQL's own channel — the one place a
22
+ * resolver needs, as `runMain` is for a process.
23
+ */
24
+ const fieldResult = async (result) => {
25
+ const settled = await result;
26
+ if (settled.isDefect()) throw new Error("fieldResult: the field's result was a defect", { cause: settled.cause });
27
+ if (settled.isErr()) throw createGraphQLError(settled.error.message, { extensions: settled.error.extensions });
28
+ return settled.value;
29
+ };
30
+ //#endregion
5
31
  //#region src/graphql.ts
6
32
  const dispose = Symbol("graphql.dispose");
33
+ /** What failed: GraphQL locates a resolver's error by wrapping it, and a log line walks `cause`, not `originalError`. */
34
+ const causeOf = (error) => typeof error === "object" && error !== null && "originalError" in error && error.originalError ? error.originalError : error;
35
+ const pathOf = (error) => typeof error === "object" && error !== null && "path" in error && Array.isArray(error.path) ? error.path.join(".") : void 0;
36
+ const noIntrospection = (context) => ({ Field: (node) => {
37
+ if (node.name.value === "__schema" || node.name.value === "__type") context.reportError(createGraphQLError(`GraphQL introspection is not allowed, but the query contained ${node.name.value}`, { nodes: [node] }));
38
+ } });
39
+ const withoutIntrospection = { onValidate: ({ addValidationRule }) => addValidationRule(noIntrospection) };
7
40
  const graphql = (api, options) => {
8
41
  const mount = options.prefix ?? "/graphql";
9
42
  let end = mount.length;
@@ -11,15 +44,19 @@ const graphql = (api, options) => {
11
44
  const prefix = mount.slice(0, end);
12
45
  const requirements = options.requires;
13
46
  const schemes = [...new Set(requirements?.flatMap((requirement) => Object.keys(requirement)) ?? [])];
47
+ const developerToolsSchema = Config.object({ developerTools: Config.pinned(options.developerTools, Config.boolean("GRAPHQL_DEVELOPER_TOOLS", { default: false })) });
14
48
  const typed = Provider.member(HttpHandler)({
15
49
  inject: {
50
+ env: Env,
16
51
  config: HttpConfig,
17
52
  units: HttpUnit,
53
+ observers: Observers,
18
54
  ...schemeDeps(schemes)
19
55
  },
20
- sync: (services) => {
56
+ make: (services) => Config.parse("GraphqlConfig", developerToolsSchema)(services.env).map(({ developerTools }) => {
21
57
  const config = services.config;
22
58
  const units = services.units;
59
+ const observers = services.observers;
23
60
  if (options.units !== void 0) {
24
61
  for (const kind of requirements === void 0 ? ["anonymous"] : schemes) if ((options.units[kind] ?? options.units["anonymous"]) !== (units[kind] ?? units["anonymous"])) throw new Error(`[graphql-server] unit kind ${JSON.stringify(kind)} differs from HttpUnit`);
25
62
  }
@@ -28,11 +65,29 @@ const graphql = (api, options) => {
28
65
  schema: options.schema,
29
66
  graphqlEndpoint: prefix,
30
67
  cors: options.cors ?? (config.corsOrigin === "" ? false : { origin: config.corsOrigin.split(",").map((origin) => origin.trim()) }),
31
- plugins: [{ onRequest: ({ serverContext, fetchAPI, endResponse }) => {
32
- if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
33
- } }, ...options.plugins ?? []],
68
+ plugins: [
69
+ { onRequest: ({ serverContext, fetchAPI, endResponse }) => {
70
+ if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
71
+ } },
72
+ ...developerTools ? [] : [withoutIntrospection],
73
+ ...options.plugins ?? []
74
+ ],
75
+ maskedErrors: { maskError: (error, message, isDev) => {
76
+ const masked = maskError(error, message, isDev);
77
+ if (masked !== error) observe(observers, {
78
+ component: "graphql",
79
+ name: "defect",
80
+ attributes: {},
81
+ details: { "graphql.path": pathOf(error) },
82
+ traced: false
83
+ })({
84
+ outcome: "error",
85
+ cause: causeOf(error)
86
+ });
87
+ return masked;
88
+ } },
34
89
  logging: false,
35
- graphiql: false,
90
+ graphiql: developerTools,
36
91
  landingPage: false,
37
92
  multipart: false,
38
93
  maxRequestBodySize: config.bodyLimit === 0 ? false : config.bodyLimit,
@@ -77,10 +132,10 @@ const graphql = (api, options) => {
77
132
  },
78
133
  [dispose]: () => yoga.dispose()
79
134
  };
80
- },
135
+ }),
81
136
  onStop: (answerer) => answerer[dispose]()
82
137
  });
83
138
  return Object.assign(typed, { authenticators: api.providers });
84
139
  };
85
140
  //#endregion
86
- export { graphql };
141
+ export { fieldResult, graphql };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/graphql-server",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "The GraphQL Yoga answerer for @btravstack/http-server",
5
5
  "keywords": [
6
6
  "graphql",
@@ -41,10 +41,11 @@
41
41
  "./package.json": "./package.json"
42
42
  },
43
43
  "devDependencies": {
44
- "@btravstack/contract": "0.26.0",
45
- "@btravstack/core": "0.26.0",
46
- "@btravstack/di": "0.26.0",
47
- "@btravstack/http-server": "0.26.0",
44
+ "@btravstack/config": "0.28.0",
45
+ "@btravstack/contract": "0.28.0",
46
+ "@btravstack/core": "0.28.0",
47
+ "@btravstack/di": "0.28.0",
48
+ "@btravstack/http-server": "0.28.0",
48
49
  "@btravstack/tsconfig": "0.4.0",
49
50
  "@types/node": "26.6.4",
50
51
  "@unthrown/vitest": "5.12.0",
@@ -58,9 +59,11 @@
58
59
  "vitest": "5.0.3"
59
60
  },
60
61
  "peerDependencies": {
61
- "@btravstack/contract": "^0.26.0",
62
- "@btravstack/di": "^0.26.0",
63
- "@btravstack/http-server": "^0.26.0",
62
+ "@btravstack/config": "^0.28.0",
63
+ "@btravstack/contract": "^0.28.0",
64
+ "@btravstack/core": "^0.28.0",
65
+ "@btravstack/di": "^0.28.0",
66
+ "@btravstack/http-server": "^0.28.0",
64
67
  "graphql": "^16.0.0 || ^17.0.0",
65
68
  "graphql-yoga": "^5.24.1",
66
69
  "unthrown": "^5.7.0"