@fluxomni/api-client 0.11.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/src/client.ts ADDED
@@ -0,0 +1,412 @@
1
+ import type { Observable } from '@apollo/client/core/index.js';
2
+ import {
3
+ ApolloClient,
4
+ ApolloError,
5
+ type ApolloLink,
6
+ type DocumentNode,
7
+ type FetchResult,
8
+ HttpLink,
9
+ InMemoryCache,
10
+ type MutationOptions,
11
+ type NormalizedCacheObject,
12
+ type OperationVariables,
13
+ type QueryOptions,
14
+ type SubscriptionOptions,
15
+ split,
16
+ } from '@apollo/client/core/index.js';
17
+ import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
18
+ import { getMainDefinition } from '@apollo/client/utilities/index.js';
19
+ import { type GraphQLFormattedError, print } from 'graphql';
20
+ import { createClient, type Client as WsClient } from 'graphql-ws';
21
+ import {
22
+ type AuthStatus,
23
+ createSessionFetch,
24
+ type LoginOptions,
25
+ login,
26
+ } from './auth.js';
27
+ import { GraphQLError, MissingDataError, NetworkError } from './errors.js';
28
+
29
+ export interface GraphQLClientConfig {
30
+ /** HTTP endpoint for queries and mutations */
31
+ httpEndpoint: string;
32
+ /** WebSocket endpoint for subscriptions (optional) */
33
+ wsEndpoint?: string;
34
+ /** Optional Authorization header for trusted/internal callers; user auth normally uses session cookies. */
35
+ authHeader?: string;
36
+ /** Cookie header for server-side session-auth callers, for example `fluxomni_session=...`. */
37
+ cookieHeader?: string;
38
+ /** Browser credential mode for GraphQL HTTP requests. */
39
+ credentials?: RequestCredentials;
40
+ /** Custom fetch function for HTTP requests */
41
+ fetch?: typeof fetch;
42
+ }
43
+
44
+ export interface ConnectionCallbacks {
45
+ onConnect?: () => void;
46
+ onDisconnect?: () => void;
47
+ onError?: (error: unknown) => void;
48
+ }
49
+
50
+ export type GraphQLClientLoginOptions = LoginOptions & {
51
+ /** HTTP endpoint for queries and mutations. Defaults to `${baseUrl}/api` or `/api` for same-origin callers. */
52
+ httpEndpoint?: string;
53
+ /** WebSocket endpoint for subscriptions. Defaults to `ws(s)://<baseUrl>/api/subscriptions` or `/api/subscriptions` for same-origin callers. */
54
+ wsEndpoint?: string;
55
+ };
56
+
57
+ export type GraphQLClientLoginResult = {
58
+ client: GraphQLClient;
59
+ status: AuthStatus;
60
+ /** `Cookie` header value for server-side callers when the runtime exposes Set-Cookie. */
61
+ cookieHeader?: string;
62
+ };
63
+
64
+ type ProcessEnvLike = {
65
+ process?: {
66
+ env?: Record<string, string | undefined>;
67
+ };
68
+ };
69
+
70
+ /**
71
+ * Low-level GraphQL client wrapping Apollo Client.
72
+ *
73
+ * Provides typed query, mutation, and subscription execution with consistent error handling.
74
+ */
75
+ export class GraphQLClient {
76
+ private readonly config: GraphQLClientConfig;
77
+ private apolloClient: ApolloClient<NormalizedCacheObject>;
78
+ private wsClient?: WsClient;
79
+
80
+ constructor(config: GraphQLClientConfig, callbacks?: ConnectionCallbacks) {
81
+ this.config = config;
82
+ const headers = clientHeaders(config);
83
+
84
+ const httpLink = new HttpLink({
85
+ uri: config.httpEndpoint,
86
+ fetch: config.fetch,
87
+ credentials: config.credentials,
88
+ headers,
89
+ });
90
+
91
+ let link: ApolloLink = httpLink;
92
+
93
+ if (config.wsEndpoint) {
94
+ this.wsClient = createClient({
95
+ url: config.wsEndpoint,
96
+ keepAlive: 10_000,
97
+ connectionParams: connectionParams(config),
98
+ on: {
99
+ connected: () => callbacks?.onConnect?.(),
100
+ closed: () => callbacks?.onDisconnect?.(),
101
+ error: (err) => callbacks?.onError?.(err),
102
+ },
103
+ });
104
+
105
+ const wsLink = new GraphQLWsLink(this.wsClient);
106
+
107
+ link = split(
108
+ ({ query }) => {
109
+ const definition = getMainDefinition(query);
110
+ return (
111
+ definition.kind === 'OperationDefinition' &&
112
+ definition.operation === 'subscription'
113
+ );
114
+ },
115
+ wsLink,
116
+ httpLink,
117
+ );
118
+ }
119
+
120
+ this.apolloClient = new ApolloClient({
121
+ link,
122
+ cache: new InMemoryCache(),
123
+ defaultOptions: {
124
+ query: { fetchPolicy: 'network-only' },
125
+ mutate: { fetchPolicy: 'no-cache' },
126
+ },
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Log in with the session endpoint and return a session-aware client.
132
+ */
133
+ static async login(
134
+ options: GraphQLClientLoginOptions,
135
+ callbacks?: ConnectionCallbacks,
136
+ ): Promise<GraphQLClientLoginResult> {
137
+ const auth = await login(options);
138
+ const endpoints = endpointsFromLoginOptions(options);
139
+ const fetchFn = auth.cookieHeader
140
+ ? createSessionFetch(auth.cookieHeader, options.fetch)
141
+ : options.fetch;
142
+
143
+ return {
144
+ client: new GraphQLClient(
145
+ {
146
+ httpEndpoint: endpoints.httpEndpoint,
147
+ wsEndpoint: endpoints.wsEndpoint,
148
+ cookieHeader: auth.cookieHeader,
149
+ credentials: options.credentials ?? 'same-origin',
150
+ fetch: fetchFn,
151
+ },
152
+ callbacks,
153
+ ),
154
+ status: auth.status,
155
+ cookieHeader: auth.cookieHeader,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Create a client from environment variables.
161
+ */
162
+ static fromEnv(
163
+ env?: Record<string, string | undefined>,
164
+ callbacks?: ConnectionCallbacks,
165
+ ): GraphQLClient {
166
+ const resolvedEnv =
167
+ env ?? (globalThis as ProcessEnvLike).process?.env ?? {};
168
+ const httpEndpoint =
169
+ resolvedEnv.FLUXOMNI_HTTP_ENDPOINT || resolvedEnv.GRAPHQL_ENDPOINT;
170
+
171
+ if (!httpEndpoint) {
172
+ throw new Error(
173
+ 'FLUXOMNI_HTTP_ENDPOINT or GRAPHQL_ENDPOINT environment variable must be set',
174
+ );
175
+ }
176
+
177
+ return new GraphQLClient(
178
+ {
179
+ httpEndpoint,
180
+ wsEndpoint: resolvedEnv.FLUXOMNI_WS_ENDPOINT,
181
+ authHeader: resolvedEnv.FLUXOMNI_AUTH || resolvedEnv.GRAPHQL_AUTH,
182
+ },
183
+ callbacks,
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Get the underlying Apollo Client instance.
189
+ */
190
+ get apollo(): ApolloClient<NormalizedCacheObject> {
191
+ return this.apolloClient;
192
+ }
193
+
194
+ /**
195
+ * Execute a GraphQL query.
196
+ */
197
+ async query<
198
+ TData,
199
+ TVariables extends OperationVariables = OperationVariables,
200
+ >(options: QueryOptions<TVariables, TData>): Promise<TData> {
201
+ try {
202
+ const result = await this.apolloClient.query<TData, TVariables>(options);
203
+
204
+ if (result.errors?.length) {
205
+ throw GraphQLError.fromGraphQLErrors(result.errors);
206
+ }
207
+
208
+ if (!result.data) {
209
+ throw new MissingDataError();
210
+ }
211
+
212
+ return result.data;
213
+ } catch (error) {
214
+ if (error instanceof GraphQLError) throw error;
215
+ if (error instanceof ApolloError) {
216
+ if (error.graphQLErrors.length) {
217
+ throw GraphQLError.fromGraphQLErrors(error.graphQLErrors);
218
+ }
219
+ if (error.networkError) {
220
+ throw new NetworkError(error.networkError.message, undefined, error);
221
+ }
222
+ }
223
+ throw new NetworkError(
224
+ error instanceof Error ? error.message : 'Unknown error',
225
+ undefined,
226
+ error instanceof Error ? error : undefined,
227
+ );
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Execute a GraphQL mutation.
233
+ */
234
+ async mutate<
235
+ TData,
236
+ TVariables extends OperationVariables = OperationVariables,
237
+ >(options: MutationOptions<TData, TVariables>): Promise<TData> {
238
+ try {
239
+ const result = await this.apolloClient.mutate<TData, TVariables>(options);
240
+
241
+ if (result.errors?.length) {
242
+ throw GraphQLError.fromGraphQLErrors(result.errors);
243
+ }
244
+
245
+ if (!result.data) {
246
+ throw new MissingDataError();
247
+ }
248
+
249
+ return result.data;
250
+ } catch (error) {
251
+ if (error instanceof GraphQLError) throw error;
252
+ if (error instanceof ApolloError) {
253
+ if (error.graphQLErrors.length) {
254
+ throw GraphQLError.fromGraphQLErrors(error.graphQLErrors);
255
+ }
256
+ if (error.networkError) {
257
+ throw new NetworkError(error.networkError.message, undefined, error);
258
+ }
259
+ }
260
+ throw new NetworkError(
261
+ error instanceof Error ? error.message : 'Unknown error',
262
+ undefined,
263
+ error instanceof Error ? error : undefined,
264
+ );
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Create a subscription observable.
270
+ */
271
+ subscribe<TData, TVariables extends OperationVariables = OperationVariables>(
272
+ options: SubscriptionOptions<TVariables, TData>,
273
+ ): Observable<FetchResult<TData>> {
274
+ return this.apolloClient.subscribe<TData, TVariables>(options);
275
+ }
276
+
277
+ /**
278
+ * Execute a raw GraphQL operation with untyped variables.
279
+ *
280
+ * This is the recommended automation surface when generated operation
281
+ * documents are more structure than needed.
282
+ */
283
+ async executeRaw<TData = unknown>(
284
+ query: DocumentNode | string,
285
+ variables?: Record<string, unknown>,
286
+ ): Promise<TData> {
287
+ const fetchFn = this.config.fetch ?? globalThis.fetch;
288
+ if (!fetchFn) {
289
+ throw new NetworkError('No fetch implementation available');
290
+ }
291
+
292
+ const response = await fetchFn(this.config.httpEndpoint, {
293
+ method: 'POST',
294
+ credentials: this.config.credentials,
295
+ headers: {
296
+ 'content-type': 'application/json',
297
+ ...clientHeaders(this.config),
298
+ },
299
+ body: JSON.stringify({
300
+ query: typeof query === 'string' ? query : print(query),
301
+ variables: variables ?? {},
302
+ }),
303
+ });
304
+
305
+ if (!response.ok) {
306
+ throw new NetworkError(
307
+ `HTTP ${response.status}: ${await response.text()}`,
308
+ response.status,
309
+ );
310
+ }
311
+
312
+ const body = (await response.json()) as {
313
+ data?: TData | null;
314
+ errors?: readonly GraphQLFormattedError[];
315
+ };
316
+
317
+ if (body.errors?.length) {
318
+ throw GraphQLError.fromGraphQLErrors(body.errors);
319
+ }
320
+
321
+ if (!body.data) {
322
+ throw new MissingDataError();
323
+ }
324
+
325
+ return body.data;
326
+ }
327
+
328
+ /**
329
+ * Stop the client and clean up resources.
330
+ */
331
+ stop(): void {
332
+ this.apolloClient.stop();
333
+ this.wsClient?.dispose();
334
+ }
335
+
336
+ /**
337
+ * Clear the Apollo cache.
338
+ */
339
+ async clearCache(): Promise<void> {
340
+ await this.apolloClient.clearStore();
341
+ }
342
+
343
+ /**
344
+ * Reset the Apollo store (clear cache and refetch active queries).
345
+ */
346
+ async resetStore(): Promise<void> {
347
+ await this.apolloClient.resetStore();
348
+ }
349
+ }
350
+
351
+ function clientHeaders(
352
+ config: GraphQLClientConfig,
353
+ ): Record<string, string> | undefined {
354
+ const headers: Record<string, string> = {};
355
+
356
+ if (config.authHeader) {
357
+ headers.Authorization = config.authHeader;
358
+ }
359
+
360
+ if (config.cookieHeader) {
361
+ headers.Cookie = config.cookieHeader;
362
+ }
363
+
364
+ return Object.keys(headers).length > 0 ? headers : undefined;
365
+ }
366
+
367
+ function connectionParams(
368
+ config: GraphQLClientConfig,
369
+ ): Record<string, string> | undefined {
370
+ const params: Record<string, string> = {};
371
+
372
+ if (config.authHeader) {
373
+ params.authorization = config.authHeader;
374
+ }
375
+
376
+ if (config.cookieHeader) {
377
+ params.cookie = config.cookieHeader;
378
+ }
379
+
380
+ return Object.keys(params).length > 0 ? params : undefined;
381
+ }
382
+
383
+ function endpointsFromLoginOptions(options: GraphQLClientLoginOptions): {
384
+ httpEndpoint: string;
385
+ wsEndpoint?: string;
386
+ } {
387
+ if (options.httpEndpoint) {
388
+ return {
389
+ httpEndpoint: options.httpEndpoint,
390
+ wsEndpoint: options.wsEndpoint,
391
+ };
392
+ }
393
+
394
+ const hasWebSocket = typeof globalThis.WebSocket !== 'undefined';
395
+
396
+ if (!options.baseUrl) {
397
+ return {
398
+ httpEndpoint: '/api',
399
+ wsEndpoint:
400
+ options.wsEndpoint ?? (hasWebSocket ? '/api/subscriptions' : undefined),
401
+ };
402
+ }
403
+
404
+ const httpEndpoint = new URL('/api', options.baseUrl).toString();
405
+ const wsEndpoint =
406
+ options.wsEndpoint ??
407
+ (hasWebSocket
408
+ ? httpEndpoint.replace(/^http(?=:)/, 'ws').concat('/subscriptions')
409
+ : undefined);
410
+
411
+ return { httpEndpoint, wsEndpoint };
412
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type { GraphQLFormattedError } from 'graphql';
2
+
3
+ /**
4
+ * Structured error details returned by the GraphQL server.
5
+ */
6
+ export interface GraphQLServerError {
7
+ message: string;
8
+ code?: string;
9
+ status?: number;
10
+ backtrace?: string[];
11
+ }
12
+
13
+ /**
14
+ * Public automation categories for GraphQL errors.
15
+ */
16
+ export type GraphQLErrorCategory =
17
+ | 'unauthenticated'
18
+ | 'forbidden'
19
+ | 'validation'
20
+ | 'not_found'
21
+ | 'conflict'
22
+ | 'persistence_failure'
23
+ | 'runtime_unavailable'
24
+ | 'operation_rejected'
25
+ | 'unknown';
26
+
27
+ const VALIDATION_CODE_PREFIXES = ['INVALID_', 'EMPTY_', 'TOO_MUCH_'];
28
+ const VALIDATION_CODE_SUFFIXES = ['_INVALID', '_VALIDATION_FAILED'];
29
+ const VALIDATION_CODE_EXACT = new Set([
30
+ 'INVALID_JSON',
31
+ 'INVALID_SPEC',
32
+ 'NO_API_KEY',
33
+ 'ROUTE_SOURCES_EMPTY',
34
+ 'WRONG_ARRAY_INDEX',
35
+ ]);
36
+ const PERSISTENCE_CODE_PARTS = ['PERSIST', 'STORAGE', 'DATABASE'];
37
+ const RUNTIME_UNAVAILABLE_CODE_PARTS = ['RUNTIME_STATE_UNAVAILABLE', 'UNAVAILABLE'];
38
+ const OPERATION_REJECTED_CODE_PARTS = ['REJECTED', 'STALE_ASSIGNMENT'];
39
+
40
+ /**
41
+ * Classify a GraphQL server error into the public automation taxonomy.
42
+ */
43
+ export function classifyGraphQLError(
44
+ error: GraphQLServerError,
45
+ ): GraphQLErrorCategory {
46
+ switch (error.status) {
47
+ case 401:
48
+ return 'unauthenticated';
49
+ case 403:
50
+ return 'forbidden';
51
+ case 404:
52
+ return 'not_found';
53
+ case 409:
54
+ return 'conflict';
55
+ case 400:
56
+ return 'validation';
57
+ default:
58
+ break;
59
+ }
60
+
61
+ const code = error.code?.toUpperCase();
62
+ if (!code) return 'unknown';
63
+ if (code === 'UNAUTHORIZED') return 'unauthenticated';
64
+ if (code === 'FORBIDDEN') return 'forbidden';
65
+ if (code.endsWith('_NOT_FOUND') || code === 'NOT_FOUND') return 'not_found';
66
+ if (code.includes('CONFLICT') || code.includes('DUPLICATE')) return 'conflict';
67
+ if (
68
+ VALIDATION_CODE_EXACT.has(code) ||
69
+ VALIDATION_CODE_PREFIXES.some((prefix) => code.startsWith(prefix)) ||
70
+ VALIDATION_CODE_SUFFIXES.some((suffix) => code.endsWith(suffix))
71
+ ) {
72
+ return 'validation';
73
+ }
74
+ if (PERSISTENCE_CODE_PARTS.some((part) => code.includes(part))) {
75
+ return 'persistence_failure';
76
+ }
77
+ if (RUNTIME_UNAVAILABLE_CODE_PARTS.some((part) => code.includes(part))) {
78
+ return 'runtime_unavailable';
79
+ }
80
+ if (OPERATION_REJECTED_CODE_PARTS.some((part) => code.includes(part))) {
81
+ return 'operation_rejected';
82
+ }
83
+
84
+ return 'unknown';
85
+ }
86
+
87
+ /**
88
+ * High-level client errors for the TypeScript GraphQL client.
89
+ */
90
+ export class ClientError extends Error {
91
+ constructor(
92
+ message: string,
93
+ public readonly cause?: Error,
94
+ ) {
95
+ super(message);
96
+ this.name = 'ClientError';
97
+ }
98
+ }
99
+
100
+ /**
101
+ * GraphQL-specific errors.
102
+ */
103
+ export class GraphQLError extends ClientError {
104
+ constructor(
105
+ message: string,
106
+ public readonly errors: GraphQLServerError[] = [],
107
+ cause?: Error,
108
+ ) {
109
+ super(message, cause);
110
+ this.name = 'GraphQLError';
111
+ }
112
+
113
+ static fromGraphQLErrors(
114
+ errors: readonly GraphQLFormattedError[],
115
+ ): GraphQLError {
116
+ const serverErrors = errors.map((err) => {
117
+ const extensions = err.extensions as Record<string, unknown> | undefined;
118
+ return {
119
+ message: err.message,
120
+ code: extensions?.code as string | undefined,
121
+ status: extensions?.status as number | undefined,
122
+ backtrace: extensions?.backtrace as string[] | undefined,
123
+ };
124
+ });
125
+ return new GraphQLError(
126
+ `GraphQL errors: ${serverErrors.map((e) => e.message).join(', ')}`,
127
+ serverErrors,
128
+ );
129
+ }
130
+
131
+ get categories(): GraphQLErrorCategory[] {
132
+ return Array.from(
133
+ new Set(this.errors.map((error) => classifyGraphQLError(error))),
134
+ );
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Network-related errors.
140
+ */
141
+ export class NetworkError extends ClientError {
142
+ constructor(
143
+ message: string,
144
+ public readonly statusCode?: number,
145
+ cause?: Error,
146
+ ) {
147
+ super(message, cause);
148
+ this.name = 'NetworkError';
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Missing field in response error.
154
+ */
155
+ export class MissingFieldError extends ClientError {
156
+ constructor(public readonly fieldName: string) {
157
+ super(`Missing field in response: ${fieldName}`);
158
+ this.name = 'MissingFieldError';
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Missing data in response error.
164
+ */
165
+ export class MissingDataError extends ClientError {
166
+ constructor() {
167
+ super('Missing data in response');
168
+ this.name = 'MissingDataError';
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Extract a required field from an optional value.
174
+ */
175
+ export function extractField<T>(
176
+ value: T | null | undefined,
177
+ fieldName: string,
178
+ ): T {
179
+ if (value === null || value === undefined) {
180
+ throw new MissingFieldError(fieldName);
181
+ }
182
+ return value;
183
+ }