@btravstack/graphql-server 0.27.0 → 0.29.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
@@ -11,7 +11,11 @@ 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
13
  options; the wildcard CORS default and Yoga console logging are disabled, and
14
- a masked defect is reported to `Observers` instead. `fieldResult(result)`
14
+ a masked defect is reported to `Observers` instead. GraphiQL,
15
+ introspection and "Did you mean …" suggestions stay off unless
16
+ `GRAPHQL_DEVELOPER_TOOLS=true` (or `developerTools: true`) turns them on. A
17
+ document over `GRAPHQL_MAX_TOKENS` (default `1000`, pinned by `maxTokens`) is
18
+ refused by the parser, before validation. `fieldResult(result)`
15
19
  answers a resolver from an `AsyncResult`, with a refusal on its own field's
16
20
  path.
17
21
  Preflights use Yoga's CORS policy before authentication, and Yoga plugins are
package/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let graphql_yoga = require("graphql-yoga");
3
+ let _btravstack_config = require("@btravstack/config");
3
4
  let _btravstack_core = require("@btravstack/core");
4
5
  let _btravstack_di = require("@btravstack/di");
5
6
  let _btravstack_http_server_internal = require("@btravstack/http-server/internal");
@@ -33,6 +34,25 @@ const dispose = Symbol("graphql.dispose");
33
34
  /** What failed: GraphQL locates a resolver's error by wrapping it, and a log line walks `cause`, not `originalError`. */
34
35
  const causeOf = (error) => typeof error === "object" && error !== null && "originalError" in error && error.originalError ? error.originalError : error;
35
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) };
41
+ const SUGGESTION = / Did you mean [^?]*\?/g;
42
+ const hideSuggestion = (error) => {
43
+ if (error instanceof Error) error.message = error.message.replace(SUGGESTION, "");
44
+ };
45
+ const hideSuggestionsIn = (payload) => (0, graphql_yoga.handleStreamOrSingleExecutionResult)(payload, ({ result }) => result.errors?.forEach(hideSuggestion));
46
+ const withoutSuggestions = {
47
+ onValidate: () => ({ result }) => result.forEach(hideSuggestion),
48
+ onExecute: () => ({ onExecuteDone: hideSuggestionsIn }),
49
+ onSubscribe: () => ({ onSubscribeResult: hideSuggestionsIn })
50
+ };
51
+ const DEFAULT_MAX_TOKENS = 1e3;
52
+ const tokenLimit = (maxTokens) => ({ onParse: ({ parseFn, setParseFn }) => setParseFn((source, options) => parseFn(source, {
53
+ ...options,
54
+ maxTokens
55
+ })) });
36
56
  const graphql = (api, options) => {
37
57
  const mount = options.prefix ?? "/graphql";
38
58
  let end = mount.length;
@@ -40,14 +60,22 @@ const graphql = (api, options) => {
40
60
  const prefix = mount.slice(0, end);
41
61
  const requirements = options.requires;
42
62
  const schemes = [...new Set(requirements?.flatMap((requirement) => Object.keys(requirement)) ?? [])];
63
+ const configSchema = _btravstack_config.Config.object({
64
+ developerTools: _btravstack_config.Config.pinned(options.developerTools, _btravstack_config.Config.boolean("GRAPHQL_DEVELOPER_TOOLS", { default: false })),
65
+ maxTokens: _btravstack_config.Config.pinned(options.maxTokens === false ? 0 : options.maxTokens, _btravstack_config.Config.integer("GRAPHQL_MAX_TOKENS", {
66
+ default: DEFAULT_MAX_TOKENS,
67
+ min: 0
68
+ }))
69
+ });
43
70
  const typed = _btravstack_di.Provider.member(_btravstack_http_server_internal.HttpHandler)({
44
71
  inject: {
72
+ env: _btravstack_config.Env,
45
73
  config: _btravstack_http_server_internal.HttpConfig,
46
74
  units: _btravstack_http_server_internal.HttpUnit,
47
75
  observers: _btravstack_core.Observers,
48
76
  ...(0, _btravstack_http_server_internal.schemeDeps)(schemes)
49
77
  },
50
- sync: (services) => {
78
+ make: (services) => _btravstack_config.Config.parse("GraphqlConfig", configSchema)(services.env).map(({ developerTools, maxTokens }) => {
51
79
  const config = services.config;
52
80
  const units = services.units;
53
81
  const observers = services.observers;
@@ -59,9 +87,14 @@ const graphql = (api, options) => {
59
87
  schema: options.schema,
60
88
  graphqlEndpoint: prefix,
61
89
  cors: options.cors ?? (config.corsOrigin === "" ? false : { origin: config.corsOrigin.split(",").map((origin) => origin.trim()) }),
62
- plugins: [{ onRequest: ({ serverContext, fetchAPI, endResponse }) => {
63
- if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
64
- } }, ...options.plugins ?? []],
90
+ plugins: [
91
+ { onRequest: ({ serverContext, fetchAPI, endResponse }) => {
92
+ if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
93
+ } },
94
+ ...developerTools ? [] : [withoutIntrospection, withoutSuggestions],
95
+ ...maxTokens === 0 ? [] : [tokenLimit(maxTokens)],
96
+ ...options.plugins ?? []
97
+ ],
65
98
  maskedErrors: { maskError: (error, message, isDev) => {
66
99
  const masked = (0, graphql_yoga.maskError)(error, message, isDev);
67
100
  if (masked !== error) (0, _btravstack_core.observe)(observers, {
@@ -77,7 +110,7 @@ const graphql = (api, options) => {
77
110
  return masked;
78
111
  } },
79
112
  logging: false,
80
- graphiql: false,
113
+ graphiql: developerTools,
81
114
  landingPage: false,
82
115
  multipart: false,
83
116
  maxRequestBodySize: config.bodyLimit === 0 ? false : config.bodyLimit,
@@ -122,7 +155,7 @@ const graphql = (api, options) => {
122
155
  },
123
156
  [dispose]: () => yoga.dispose()
124
157
  };
125
- },
158
+ }),
126
159
  onStop: (answerer) => answerer[dispose]()
127
160
  });
128
161
  return Object.assign(typed, { authenticators: api.providers });
package/dist/index.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { GraphQLError, GraphQLSchema } from "graphql";
2
2
  import { AsyncResult, Result } from "unthrown";
3
3
  import { IncomingMessage } from "node:http";
4
+ import { ConfigInvalid, EnvReading } from "@btravstack/config";
4
5
  import { OneScheme, Requirements } from "@btravstack/contract";
5
6
  import { Observers } from "@btravstack/core";
6
7
  import { AnyPort, Provider, Scope } from "@btravstack/di";
@@ -64,6 +65,19 @@ type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extend
64
65
  readonly schema: GraphQLSchema;
65
66
  readonly prefix?: `/${string}`;
66
67
  readonly cors?: YogaCors;
68
+ /**
69
+ * GraphiQL, introspection and the "Did you mean …" suggestions in an error,
70
+ * all or none — pins `GRAPHQL_DEVELOPER_TOOLS`, which defaults to `false`, so
71
+ * a deployment exposes none of them unless it says so.
72
+ */
73
+ readonly developerTools?: boolean;
74
+ /**
75
+ * Pins `GRAPHQL_MAX_TOKENS` (default `1000`) — the most tokens graphql-js
76
+ * parses in one document before refusing it, so an oversized document is
77
+ * refused before any validation rule runs on it. `false` pins the unbounded
78
+ * `0`.
79
+ */
80
+ readonly maxTokens?: number | false;
67
81
  /**
68
82
  * Yoga plugins. Each may be typed by the context it reads — a
69
83
  * `Plugin<{ unit: … }>` — and is checked against the {@link GraphqlContext}
@@ -74,7 +88,7 @@ type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extend
74
88
  readonly unit?: U & UnitGate<U, Units, KindOf<R>>;
75
89
  readonly requires?: R & RequiresGate<R, { readonly [K in keyof A]: A[K]["scope"]; }>;
76
90
  };
77
- 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, never, HttpConfig | HttpUnit | Observers | Scope | SchemePortsOf<R> | UnitsNeedsOf<Units>> & {
91
+ 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" | "GRAPHQL_MAX_TOKENS">> & {
78
92
  authenticators: readonly import("@btravstack/http-server/internal").SchemeProviders<A>[];
79
93
  };
80
94
  //#endregion
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Plugin, YogaInitialContext, YogaServerOptions } from "graphql-yoga";
2
+ import { ConfigInvalid, EnvReading } from "@btravstack/config";
2
3
  import { Observers } from "@btravstack/core";
3
4
  import { AnyPort, Provider, Scope } from "@btravstack/di";
4
5
  import { Authenticators, HttpAuth, HttpConfig, HttpHandler, HttpUnit, KindOf, Kinds, Principal, RequiresGate, SchemePortsOf, SchemesFrom, SchemesOf, UnitFor, UnitsNeedsOf, UnitsOf } from "@btravstack/http-server/internal";
@@ -64,6 +65,19 @@ type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extend
64
65
  readonly schema: GraphQLSchema;
65
66
  readonly prefix?: `/${string}`;
66
67
  readonly cors?: YogaCors;
68
+ /**
69
+ * GraphiQL, introspection and the "Did you mean …" suggestions in an error,
70
+ * all or none — pins `GRAPHQL_DEVELOPER_TOOLS`, which defaults to `false`, so
71
+ * a deployment exposes none of them unless it says so.
72
+ */
73
+ readonly developerTools?: boolean;
74
+ /**
75
+ * Pins `GRAPHQL_MAX_TOKENS` (default `1000`) — the most tokens graphql-js
76
+ * parses in one document before refusing it, so an oversized document is
77
+ * refused before any validation rule runs on it. `false` pins the unbounded
78
+ * `0`.
79
+ */
80
+ readonly maxTokens?: number | false;
67
81
  /**
68
82
  * Yoga plugins. Each may be typed by the context it reads — a
69
83
  * `Plugin<{ unit: … }>` — and is checked against the {@link GraphqlContext}
@@ -74,7 +88,7 @@ type GraphqlOptions<A extends Authenticators, Units extends UnitsOf<A>, R extend
74
88
  readonly unit?: U & UnitGate<U, Units, KindOf<R>>;
75
89
  readonly requires?: R & RequiresGate<R, { readonly [K in keyof A]: A[K]["scope"]; }>;
76
90
  };
77
- 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, never, HttpConfig | HttpUnit | Observers | Scope | SchemePortsOf<R> | UnitsNeedsOf<Units>> & {
91
+ 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" | "GRAPHQL_MAX_TOKENS">> & {
78
92
  authenticators: readonly import("@btravstack/http-server/internal").SchemeProviders<A>[];
79
93
  };
80
94
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { createGraphQLError, createYoga, maskError } from "graphql-yoga";
1
+ import { createGraphQLError, createYoga, handleStreamOrSingleExecutionResult, maskError } from "graphql-yoga";
2
+ import { Config, Env } from "@btravstack/config";
2
3
  import { Observers, observe } from "@btravstack/core";
3
4
  import { Provider } from "@btravstack/di";
4
5
  import { HttpConfig, HttpHandler, HttpUnit, forkUnit, principalOf, resolveScheme, schemeDeps, schemeServices } from "@btravstack/http-server/internal";
@@ -32,6 +33,25 @@ const dispose = Symbol("graphql.dispose");
32
33
  /** What failed: GraphQL locates a resolver's error by wrapping it, and a log line walks `cause`, not `originalError`. */
33
34
  const causeOf = (error) => typeof error === "object" && error !== null && "originalError" in error && error.originalError ? error.originalError : error;
34
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) };
40
+ const SUGGESTION = / Did you mean [^?]*\?/g;
41
+ const hideSuggestion = (error) => {
42
+ if (error instanceof Error) error.message = error.message.replace(SUGGESTION, "");
43
+ };
44
+ const hideSuggestionsIn = (payload) => handleStreamOrSingleExecutionResult(payload, ({ result }) => result.errors?.forEach(hideSuggestion));
45
+ const withoutSuggestions = {
46
+ onValidate: () => ({ result }) => result.forEach(hideSuggestion),
47
+ onExecute: () => ({ onExecuteDone: hideSuggestionsIn }),
48
+ onSubscribe: () => ({ onSubscribeResult: hideSuggestionsIn })
49
+ };
50
+ const DEFAULT_MAX_TOKENS = 1e3;
51
+ const tokenLimit = (maxTokens) => ({ onParse: ({ parseFn, setParseFn }) => setParseFn((source, options) => parseFn(source, {
52
+ ...options,
53
+ maxTokens
54
+ })) });
35
55
  const graphql = (api, options) => {
36
56
  const mount = options.prefix ?? "/graphql";
37
57
  let end = mount.length;
@@ -39,14 +59,22 @@ const graphql = (api, options) => {
39
59
  const prefix = mount.slice(0, end);
40
60
  const requirements = options.requires;
41
61
  const schemes = [...new Set(requirements?.flatMap((requirement) => Object.keys(requirement)) ?? [])];
62
+ const configSchema = Config.object({
63
+ developerTools: Config.pinned(options.developerTools, Config.boolean("GRAPHQL_DEVELOPER_TOOLS", { default: false })),
64
+ maxTokens: Config.pinned(options.maxTokens === false ? 0 : options.maxTokens, Config.integer("GRAPHQL_MAX_TOKENS", {
65
+ default: DEFAULT_MAX_TOKENS,
66
+ min: 0
67
+ }))
68
+ });
42
69
  const typed = Provider.member(HttpHandler)({
43
70
  inject: {
71
+ env: Env,
44
72
  config: HttpConfig,
45
73
  units: HttpUnit,
46
74
  observers: Observers,
47
75
  ...schemeDeps(schemes)
48
76
  },
49
- sync: (services) => {
77
+ make: (services) => Config.parse("GraphqlConfig", configSchema)(services.env).map(({ developerTools, maxTokens }) => {
50
78
  const config = services.config;
51
79
  const units = services.units;
52
80
  const observers = services.observers;
@@ -58,9 +86,14 @@ const graphql = (api, options) => {
58
86
  schema: options.schema,
59
87
  graphqlEndpoint: prefix,
60
88
  cors: options.cors ?? (config.corsOrigin === "" ? false : { origin: config.corsOrigin.split(",").map((origin) => origin.trim()) }),
61
- plugins: [{ onRequest: ({ serverContext, fetchAPI, endResponse }) => {
62
- if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
63
- } }, ...options.plugins ?? []],
89
+ plugins: [
90
+ { onRequest: ({ serverContext, fetchAPI, endResponse }) => {
91
+ if (serverContext.refusalStatus !== void 0) endResponse(new fetchAPI.Response(null, { status: serverContext.refusalStatus }));
92
+ } },
93
+ ...developerTools ? [] : [withoutIntrospection, withoutSuggestions],
94
+ ...maxTokens === 0 ? [] : [tokenLimit(maxTokens)],
95
+ ...options.plugins ?? []
96
+ ],
64
97
  maskedErrors: { maskError: (error, message, isDev) => {
65
98
  const masked = maskError(error, message, isDev);
66
99
  if (masked !== error) observe(observers, {
@@ -76,7 +109,7 @@ const graphql = (api, options) => {
76
109
  return masked;
77
110
  } },
78
111
  logging: false,
79
- graphiql: false,
112
+ graphiql: developerTools,
80
113
  landingPage: false,
81
114
  multipart: false,
82
115
  maxRequestBodySize: config.bodyLimit === 0 ? false : config.bodyLimit,
@@ -121,7 +154,7 @@ const graphql = (api, options) => {
121
154
  },
122
155
  [dispose]: () => yoga.dispose()
123
156
  };
124
- },
157
+ }),
125
158
  onStop: (answerer) => answerer[dispose]()
126
159
  });
127
160
  return Object.assign(typed, { authenticators: api.providers });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/graphql-server",
3
- "version": "0.27.0",
3
+ "version": "0.29.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.27.0",
45
- "@btravstack/core": "0.27.0",
46
- "@btravstack/di": "0.27.0",
47
- "@btravstack/http-server": "0.27.0",
44
+ "@btravstack/config": "0.29.0",
45
+ "@btravstack/contract": "0.29.0",
46
+ "@btravstack/core": "0.29.0",
47
+ "@btravstack/di": "0.29.0",
48
+ "@btravstack/http-server": "0.29.0",
48
49
  "@btravstack/tsconfig": "0.4.0",
49
50
  "@types/node": "26.6.4",
50
51
  "@unthrown/vitest": "5.12.0",
@@ -58,11 +59,12 @@
58
59
  "vitest": "5.0.3"
59
60
  },
60
61
  "peerDependencies": {
61
- "@btravstack/contract": "^0.27.0",
62
- "@btravstack/core": "^0.27.0",
63
- "@btravstack/di": "^0.27.0",
64
- "@btravstack/http-server": "^0.27.0",
65
- "graphql": "^16.0.0 || ^17.0.0",
62
+ "@btravstack/config": "^0.29.0",
63
+ "@btravstack/contract": "^0.29.0",
64
+ "@btravstack/core": "^0.29.0",
65
+ "@btravstack/di": "^0.29.0",
66
+ "@btravstack/http-server": "^0.29.0",
67
+ "graphql": "^16.6.0 || ^17.0.0",
66
68
  "graphql-yoga": "^5.24.1",
67
69
  "unthrown": "^5.7.0"
68
70
  },