@eleven-am/golem-client 0.0.0-stage → 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,61 @@
1
+ import { GolemError, type GraphQLResponseBody } from './errors.js';
2
+ import { type ContractManifest, type SessionStatus } from './protocol.js';
3
+ export interface Credentials {
4
+ readonly headers?: Readonly<Record<string, string>>;
5
+ readonly connectionParams?: Readonly<Record<string, unknown>>;
6
+ }
7
+ export type CredentialsGetter = () => Credentials | Promise<Credentials>;
8
+ export interface SocketLike {
9
+ readonly readyState: number;
10
+ }
11
+ export type SocketFactory = (url: string, protocols: string | string[], credentials: Credentials) => SocketLike;
12
+ export interface SubscriptionRequest {
13
+ readonly query: string;
14
+ readonly variables?: Readonly<Record<string, unknown>>;
15
+ }
16
+ export type StreamEnd = {
17
+ readonly kind: 'complete';
18
+ } | {
19
+ readonly kind: 'error';
20
+ readonly errors: ReadonlyArray<GolemError>;
21
+ } | {
22
+ readonly kind: 'reauthenticate';
23
+ } | {
24
+ readonly kind: 'fatal';
25
+ readonly status: SessionStatus;
26
+ readonly error: Error;
27
+ };
28
+ export interface StreamHandlers {
29
+ onAck(): void;
30
+ onEvent(body: GraphQLResponseBody): void;
31
+ onInterrupted(): void;
32
+ onEnd(end: StreamEnd): void;
33
+ }
34
+ export interface SessionOptions {
35
+ readonly url: string;
36
+ readonly manifest: ContractManifest;
37
+ readonly credentials?: CredentialsGetter;
38
+ readonly createSocket?: SocketFactory;
39
+ readonly random?: () => number;
40
+ readonly sleep?: (milliseconds: number) => Promise<void>;
41
+ }
42
+ export declare class Session {
43
+ private readonly options;
44
+ private client;
45
+ private currentStatus;
46
+ private readonly listeners;
47
+ private readonly active;
48
+ private connectionCredentials;
49
+ private acknowledged;
50
+ private latched;
51
+ private lastCloseAcknowledged;
52
+ constructor(options: SessionOptions);
53
+ get status(): SessionStatus;
54
+ onStatus(listener: (status: SessionStatus) => void): () => void;
55
+ subscribe(request: SubscriptionRequest, handlers: StreamHandlers): () => void;
56
+ renew(): void;
57
+ dispose(): void;
58
+ private fail;
59
+ private setStatus;
60
+ private connection;
61
+ }
@@ -0,0 +1,188 @@
1
+ import { createClient } from 'graphql-ws';
2
+ import { GolemError, GolemProtocolError } from './errors.js';
3
+ import { AckTracker, backoffDelay, classifyClose, golemBlock, isFatalHandshakeStatus } from './protocol.js';
4
+ function isCloseEventLike(value) {
5
+ return typeof value === 'object' && value !== null && 'code' in value && typeof value.code === 'number';
6
+ }
7
+ function reasonOf(event) {
8
+ return typeof event.reason === 'string' ? event.reason : '';
9
+ }
10
+ function defaultSleep(milliseconds) {
11
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
12
+ }
13
+ function socketUrl(url) {
14
+ return url.replace(/^http/, 'ws');
15
+ }
16
+ function socketClass(factory, credentials) {
17
+ return class {
18
+ static CONNECTING = 0;
19
+ static OPEN = 1;
20
+ static CLOSING = 2;
21
+ static CLOSED = 3;
22
+ constructor(url, protocols) {
23
+ return factory(url, protocols, credentials());
24
+ }
25
+ };
26
+ }
27
+ export class Session {
28
+ options;
29
+ client;
30
+ currentStatus = 'idle';
31
+ listeners = new Set();
32
+ active = new Set();
33
+ connectionCredentials = {};
34
+ acknowledged = false;
35
+ latched;
36
+ lastCloseAcknowledged = false;
37
+ constructor(options) {
38
+ this.options = options;
39
+ }
40
+ get status() {
41
+ return this.currentStatus;
42
+ }
43
+ onStatus(listener) {
44
+ this.listeners.add(listener);
45
+ return () => {
46
+ this.listeners.delete(listener);
47
+ };
48
+ }
49
+ subscribe(request, handlers) {
50
+ if (this.latched !== undefined) {
51
+ const { status, error } = this.latched;
52
+ queueMicrotask(() => handlers.onEnd({ kind: 'fatal', status, error }));
53
+ return () => undefined;
54
+ }
55
+ const stream = { tracker: new AckTracker(), handlers };
56
+ this.active.add(stream);
57
+ let ended = false;
58
+ const finish = (end) => {
59
+ if (ended) {
60
+ return;
61
+ }
62
+ ended = true;
63
+ this.active.delete(stream);
64
+ handlers.onEnd(end);
65
+ };
66
+ const dispose = this.connection().subscribe({ query: request.query, variables: request.variables }, {
67
+ next: (value) => {
68
+ if (ended) {
69
+ return;
70
+ }
71
+ const outcome = stream.tracker.accept(value);
72
+ switch (outcome.kind) {
73
+ case 'ack':
74
+ handlers.onAck();
75
+ return;
76
+ case 'event':
77
+ handlers.onEvent(outcome.body);
78
+ return;
79
+ case 'error':
80
+ finish({ kind: 'error', errors: outcome.errors });
81
+ dispose();
82
+ return;
83
+ case 'violation':
84
+ finish({ kind: 'fatal', status: 'invalid', error: outcome.error });
85
+ dispose();
86
+ }
87
+ },
88
+ error: (reason) => {
89
+ if (isCloseEventLike(reason)) {
90
+ const disposition = classifyClose(reason.code, reasonOf(reason), this.lastCloseAcknowledged);
91
+ if (disposition.action === 'fatal') {
92
+ this.fail(disposition.status, disposition.error);
93
+ finish({ kind: 'fatal', status: disposition.status, error: disposition.error });
94
+ return;
95
+ }
96
+ if (disposition.action === 'reauthenticate') {
97
+ finish({ kind: 'reauthenticate' });
98
+ return;
99
+ }
100
+ finish({ kind: 'fatal', status: 'invalid', error: new GolemProtocolError(`the socket closed with ${reason.code} and was not retried`) });
101
+ return;
102
+ }
103
+ if (Array.isArray(reason)) {
104
+ finish({ kind: 'error', errors: reason.map((shape) => new GolemError(shape)) });
105
+ return;
106
+ }
107
+ finish({ kind: 'fatal', status: 'invalid', error: reason instanceof Error ? reason : new GolemProtocolError(String(reason)) });
108
+ },
109
+ complete: () => finish({ kind: 'complete' }),
110
+ });
111
+ return () => {
112
+ if (!ended) {
113
+ ended = true;
114
+ this.active.delete(stream);
115
+ dispose();
116
+ }
117
+ };
118
+ }
119
+ renew() {
120
+ this.client?.terminate();
121
+ }
122
+ dispose() {
123
+ this.client?.dispose();
124
+ this.client = undefined;
125
+ this.active.clear();
126
+ this.setStatus('idle');
127
+ }
128
+ fail(status, error) {
129
+ if (isFatalHandshakeStatus(status)) {
130
+ this.latched = { status, error };
131
+ }
132
+ this.setStatus(status);
133
+ }
134
+ setStatus(status) {
135
+ if (status === this.currentStatus) {
136
+ return;
137
+ }
138
+ this.currentStatus = status;
139
+ for (const listener of [...this.listeners]) {
140
+ listener(status);
141
+ }
142
+ }
143
+ connection() {
144
+ if (this.client !== undefined) {
145
+ return this.client;
146
+ }
147
+ const { options } = this;
148
+ const sleep = options.sleep ?? defaultSleep;
149
+ const random = options.random ?? Math.random;
150
+ const webSocketImpl = options.createSocket === undefined ? undefined : socketClass(options.createSocket, () => this.connectionCredentials);
151
+ const client = createClient({
152
+ url: async () => {
153
+ this.connectionCredentials = (await options.credentials?.()) ?? {};
154
+ return socketUrl(options.url);
155
+ },
156
+ connectionParams: () => ({ ...this.connectionCredentials.connectionParams, golem: golemBlock(options.manifest) }),
157
+ lazy: true,
158
+ retryAttempts: Number.POSITIVE_INFINITY,
159
+ retryWait: (retries) => sleep(backoffDelay(retries, random)),
160
+ shouldRetry: (event) => !isCloseEventLike(event) || classifyClose(event.code, reasonOf(event), this.lastCloseAcknowledged).action === 'retry',
161
+ ...(webSocketImpl === undefined ? {} : { webSocketImpl }),
162
+ on: {
163
+ connecting: (retrying) => this.setStatus(retrying ? 'reconnecting' : 'connecting'),
164
+ connected: () => {
165
+ this.acknowledged = true;
166
+ this.setStatus('open');
167
+ },
168
+ closed: (event) => {
169
+ this.lastCloseAcknowledged = this.acknowledged;
170
+ this.acknowledged = false;
171
+ if (this.currentStatus === 'open') {
172
+ this.setStatus('reconnecting');
173
+ }
174
+ if (isCloseEventLike(event) && classifyClose(event.code, reasonOf(event), this.lastCloseAcknowledged).action === 'retry') {
175
+ for (const stream of this.active) {
176
+ if (stream.tracker.live) {
177
+ stream.tracker.reset();
178
+ stream.handlers.onInterrupted();
179
+ }
180
+ }
181
+ }
182
+ },
183
+ },
184
+ });
185
+ this.client = client;
186
+ return client;
187
+ }
188
+ }
@@ -0,0 +1,27 @@
1
+ import { type ContractManifest, type SessionStatus } from './protocol.js';
2
+ import { type CredentialsGetter, type SocketFactory, type StreamHandlers, type SubscriptionRequest } from './session.js';
3
+ export interface TransportOptions {
4
+ readonly url: string;
5
+ readonly manifest: ContractManifest;
6
+ readonly credentials?: CredentialsGetter;
7
+ readonly fetch?: typeof globalThis.fetch;
8
+ readonly createSocket?: SocketFactory;
9
+ readonly random?: () => number;
10
+ readonly sleep?: (milliseconds: number) => Promise<void>;
11
+ }
12
+ export interface ExecuteRequest {
13
+ readonly query: string;
14
+ readonly variables?: Readonly<Record<string, unknown>>;
15
+ readonly signal?: AbortSignal;
16
+ }
17
+ export declare class Transport {
18
+ private readonly options;
19
+ private readonly session;
20
+ constructor(options: TransportOptions);
21
+ get status(): SessionStatus;
22
+ onStatus(listener: (status: SessionStatus) => void): () => void;
23
+ subscribe(request: SubscriptionRequest, handlers: StreamHandlers): () => void;
24
+ renew(): void;
25
+ dispose(): void;
26
+ execute<T = unknown>(request: ExecuteRequest): Promise<T>;
27
+ }
@@ -0,0 +1,62 @@
1
+ import { GolemConfigurationError, GolemHttpError, GolemProtocolError, failureOf } from './errors.js';
2
+ import { golemBlock } from './protocol.js';
3
+ import { Session } from './session.js';
4
+ export class Transport {
5
+ options;
6
+ session;
7
+ constructor(options) {
8
+ if (options.credentials !== undefined && typeof options.credentials !== 'function') {
9
+ throw new GolemConfigurationError('credentials must be a function returning headers and connection params');
10
+ }
11
+ this.options = options;
12
+ this.session = new Session(options);
13
+ }
14
+ get status() {
15
+ return this.session.status;
16
+ }
17
+ onStatus(listener) {
18
+ return this.session.onStatus(listener);
19
+ }
20
+ subscribe(request, handlers) {
21
+ return this.session.subscribe(request, handlers);
22
+ }
23
+ renew() {
24
+ this.session.renew();
25
+ }
26
+ dispose() {
27
+ this.session.dispose();
28
+ }
29
+ async execute(request) {
30
+ const credentials = (await this.options.credentials?.()) ?? {};
31
+ const send = this.options.fetch ?? globalThis.fetch;
32
+ const response = await send(this.options.url, {
33
+ method: 'POST',
34
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...credentials.headers },
35
+ body: JSON.stringify({
36
+ query: request.query,
37
+ variables: request.variables,
38
+ extensions: { golem: golemBlock(this.options.manifest) },
39
+ }),
40
+ signal: request.signal,
41
+ });
42
+ const text = await response.text();
43
+ let body;
44
+ try {
45
+ body = JSON.parse(text);
46
+ }
47
+ catch {
48
+ throw new GolemHttpError(response.status, `HTTP ${response.status} with a non-JSON body`);
49
+ }
50
+ const failure = failureOf(body);
51
+ if (failure !== undefined) {
52
+ throw failure;
53
+ }
54
+ if (!response.ok) {
55
+ throw new GolemHttpError(response.status, `HTTP ${response.status} without GraphQL errors`);
56
+ }
57
+ if (body.data === undefined) {
58
+ throw new GolemProtocolError('the response carried neither data nor errors');
59
+ }
60
+ return body.data;
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@eleven-am/golem-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Live GraphQL client for golem backends",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "tsc --noEmit -p tsconfig.spec.json && vitest run"
21
+ },
22
+ "peerDependencies": {
23
+ "@tanstack/query-core": "^5.0.0",
24
+ "graphql": "^16.0.0",
25
+ "graphql-ws": "^6.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@tanstack/query-core": "^5.0.0",
29
+ "@types/node": "^22.10.0",
30
+ "graphql": "^16.11.0",
31
+ "graphql-ws": "^6.0.0",
32
+ "jsdom": "^26.0.0",
33
+ "typescript": "^5.7.0",
34
+ "vitest": "^3.0.0"
35
+ },
36
+ "license": "GPL-3.0",
37
+ "author": "Roy OSSAI",
38
+ "engines": {
39
+ "node": ">=20.19"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/eleven-am/golem.git",
47
+ "directory": "client/core"
48
+ }
49
+ }