@graphorin/client 0.6.1 → 0.7.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/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/dist/errors.d.ts +7 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/graphorin-client.d.ts +22 -0
- package/dist/graphorin-client.d.ts.map +1 -1
- package/dist/graphorin-client.js +17 -2
- package/dist/graphorin-client.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/reconnect.d.ts +3 -3
- package/dist/reconnect.js +2 -2
- package/dist/reconnect.js.map +1 -1
- package/dist/transport/types.d.ts +1 -1
- package/package.json +12 -11
- package/src/errors.ts +170 -0
- package/src/graphorin-client.ts +1075 -0
- package/src/index.ts +49 -0
- package/src/reconnect.ts +91 -0
- package/src/transport/index.ts +16 -0
- package/src/transport/sse.ts +174 -0
- package/src/transport/types.ts +106 -0
- package/src/transport/ws.ts +211 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@graphorin/client` - reference TypeScript client for the
|
|
3
|
+
* Graphorin standalone server.
|
|
4
|
+
*
|
|
5
|
+
* Wraps the WebSocket subprotocol `graphorin.protocol.v1` (with an
|
|
6
|
+
* optional Server-Sent Events fallback for proxy-restricted
|
|
7
|
+
* environments) behind an ergonomic {@link GraphorinClient} class:
|
|
8
|
+
* `connect()`, `subscribe({ target, id })` returning an
|
|
9
|
+
* async-iterable subscription, `cancel(runId, opts)`,
|
|
10
|
+
* `disconnect()`. Handles the browser ticket flow, exponential-
|
|
11
|
+
* backoff reconnect with `lastEventId` resume against the server
|
|
12
|
+
* replay buffer, and Zod-validated frame parsing on both
|
|
13
|
+
* directions.
|
|
14
|
+
*
|
|
15
|
+
* Browser-friendly: zero Node-only dependencies; the only runtime
|
|
16
|
+
* dependencies are `@graphorin/protocol` and `zod`.
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Canonical version constant, derived from `package.json` at build time. */
|
|
22
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
23
|
+
|
|
24
|
+
export const VERSION: string = pkg.version;
|
|
25
|
+
|
|
26
|
+
export * from './errors.js';
|
|
27
|
+
export {
|
|
28
|
+
GraphorinClient,
|
|
29
|
+
type GraphorinClientOptions,
|
|
30
|
+
type Subscription,
|
|
31
|
+
type SubscriptionMetadata,
|
|
32
|
+
type SubscriptionTarget,
|
|
33
|
+
type TransportPreference,
|
|
34
|
+
} from './graphorin-client.js';
|
|
35
|
+
export {
|
|
36
|
+
type BackoffPolicy,
|
|
37
|
+
computeBackoffMs,
|
|
38
|
+
sleep,
|
|
39
|
+
} from './reconnect.js';
|
|
40
|
+
export {
|
|
41
|
+
openSseTransport,
|
|
42
|
+
openWebSocketTransport,
|
|
43
|
+
type Transport,
|
|
44
|
+
type TransportAuth,
|
|
45
|
+
type TransportCloseReason,
|
|
46
|
+
type TransportKind,
|
|
47
|
+
type TransportListeners,
|
|
48
|
+
type TransportOptions,
|
|
49
|
+
} from './transport/index.js';
|
package/src/reconnect.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-functional reconnect-backoff helper. Encapsulated in its own
|
|
3
|
+
* module so the `GraphorinClient` stays free of timing
|
|
4
|
+
* heuristics - and so tests can drive the policy with a deterministic
|
|
5
|
+
* RNG.
|
|
6
|
+
*
|
|
7
|
+
* Algorithm: exponential backoff with full-jitter
|
|
8
|
+
* (`delay = random(0, min(maxMs, baseMs * 2^attempt))`). The
|
|
9
|
+
* implementation matches the AWS Architecture Blog "exponential
|
|
10
|
+
* backoff and jitter" reference but is otherwise an original
|
|
11
|
+
* formulation.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stable shape consumed by {@link computeBackoffMs}.
|
|
18
|
+
*
|
|
19
|
+
* @stable
|
|
20
|
+
*/
|
|
21
|
+
export interface BackoffPolicy {
|
|
22
|
+
/** Initial slot in milliseconds. Default `500`. */
|
|
23
|
+
readonly baseMs?: number;
|
|
24
|
+
/** Cap on every individual sleep. Default `30_000`. */
|
|
25
|
+
readonly maxMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Hard cap on the number of attempts. The client surfaces a
|
|
28
|
+
* `TransportFailedError` once exceeded. Default `Infinity`.
|
|
29
|
+
*/
|
|
30
|
+
readonly maxAttempts?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Optional injection seam used by tests; defaults to `Math.random`.
|
|
33
|
+
*/
|
|
34
|
+
readonly random?: () => number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute the number of milliseconds to sleep before the
|
|
39
|
+
* `attempt`-th reconnect (1-indexed). Returns `null` when the policy
|
|
40
|
+
* has been exhausted (`attempt > maxAttempts`).
|
|
41
|
+
*
|
|
42
|
+
* @stable
|
|
43
|
+
*/
|
|
44
|
+
export function computeBackoffMs(attempt: number, policy: BackoffPolicy = {}): number | null {
|
|
45
|
+
if (!Number.isFinite(attempt) || attempt < 1) {
|
|
46
|
+
throw new RangeError('computeBackoffMs: attempt must be a positive integer.');
|
|
47
|
+
}
|
|
48
|
+
const baseMs = policy.baseMs ?? 500;
|
|
49
|
+
const maxMs = policy.maxMs ?? 30_000;
|
|
50
|
+
const maxAttempts = policy.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
51
|
+
const random = policy.random ?? Math.random;
|
|
52
|
+
if (attempt > maxAttempts) return null;
|
|
53
|
+
// Exponential growth with full jitter, clamped to maxMs.
|
|
54
|
+
const exp = Math.min(maxMs, baseMs * 2 ** Math.min(attempt - 1, 30));
|
|
55
|
+
return Math.floor(random() * exp);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve when the requested number of milliseconds elapsed, or
|
|
60
|
+
* reject (with a `DOMException`-style abort error) when the
|
|
61
|
+
* supplied `AbortSignal` fires first.
|
|
62
|
+
*
|
|
63
|
+
* @stable
|
|
64
|
+
*/
|
|
65
|
+
export function sleep(durationMs: number, signal?: AbortSignal): Promise<void> {
|
|
66
|
+
if (signal?.aborted) {
|
|
67
|
+
return Promise.reject(abortError(signal.reason));
|
|
68
|
+
}
|
|
69
|
+
return new Promise<void>((resolve, reject) => {
|
|
70
|
+
const timeout = setTimeout(() => {
|
|
71
|
+
cleanup();
|
|
72
|
+
resolve();
|
|
73
|
+
}, durationMs);
|
|
74
|
+
const onAbort = (): void => {
|
|
75
|
+
cleanup();
|
|
76
|
+
reject(abortError(signal?.reason));
|
|
77
|
+
};
|
|
78
|
+
const cleanup = (): void => {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
signal?.removeEventListener('abort', onAbort);
|
|
81
|
+
};
|
|
82
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function abortError(reason: unknown): Error {
|
|
87
|
+
if (reason instanceof Error) return reason;
|
|
88
|
+
const err = new Error('Aborted.');
|
|
89
|
+
err.name = 'AbortError';
|
|
90
|
+
return err;
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport barrel for `@graphorin/client`.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { openSseTransport } from './sse.js';
|
|
8
|
+
export type {
|
|
9
|
+
Transport,
|
|
10
|
+
TransportAuth,
|
|
11
|
+
TransportCloseReason,
|
|
12
|
+
TransportKind,
|
|
13
|
+
TransportListeners,
|
|
14
|
+
TransportOptions,
|
|
15
|
+
} from './types.js';
|
|
16
|
+
export { openWebSocketTransport } from './ws.js';
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent-Events transport - the read-only fallback for clients
|
|
3
|
+
* stuck behind proxies that block WebSocket upgrades. The transport
|
|
4
|
+
* is unidirectional (server → client only); control-plane operations
|
|
5
|
+
* (subscribe / abort / resume) must go through REST endpoints
|
|
6
|
+
* exposed by `@graphorin/server`.
|
|
7
|
+
*
|
|
8
|
+
* IP-3: implemented on **fetch streaming**, not `EventSource`. The
|
|
9
|
+
* server writes *named* events (`event: <frame.type>`), which an
|
|
10
|
+
* `EventSource` delivers only to listeners registered per type - an
|
|
11
|
+
* unbounded catalogue the old `message`-only listener never saw.
|
|
12
|
+
* Every frame's full JSON rides in `data:`, so a direct SSE parse
|
|
13
|
+
* that ignores the event name delivers everything - and `fetch`
|
|
14
|
+
* sends the `Authorization` header natively (no polyfill seam).
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ServerMessageSchema } from '@graphorin/protocol';
|
|
20
|
+
|
|
21
|
+
import { InvalidServerFrameError, TransportFailedError } from '../errors.js';
|
|
22
|
+
import type { Transport, TransportListeners, TransportOptions } from './types.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Open an SSE transport. Resolves once the server answers with a
|
|
26
|
+
* streaming response; rejects with a typed
|
|
27
|
+
* {@link TransportFailedError} on construction / connection failure.
|
|
28
|
+
*
|
|
29
|
+
* @stable
|
|
30
|
+
*/
|
|
31
|
+
export async function openSseTransport(
|
|
32
|
+
options: TransportOptions,
|
|
33
|
+
listeners: TransportListeners,
|
|
34
|
+
): Promise<Transport> {
|
|
35
|
+
if (options.auth.kind !== 'bearer') {
|
|
36
|
+
throw new TransportFailedError(
|
|
37
|
+
`SSE transport supports only the 'bearer' auth strategy; got '${options.auth.kind}'.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
41
|
+
if (typeof fetchImpl !== 'function') {
|
|
42
|
+
throw new TransportFailedError('No fetch implementation available for the SSE transport.');
|
|
43
|
+
}
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
let resp: Response;
|
|
46
|
+
try {
|
|
47
|
+
resp = await fetchImpl(options.url, {
|
|
48
|
+
method: 'GET',
|
|
49
|
+
headers: {
|
|
50
|
+
Accept: 'text/event-stream',
|
|
51
|
+
Authorization: `Bearer ${options.auth.token}`,
|
|
52
|
+
// periphery-03: resume from the replay buffer instead of
|
|
53
|
+
// re-receiving the entire buffered history on every reconnect.
|
|
54
|
+
...(options.lastEventId !== undefined && options.lastEventId.length > 0
|
|
55
|
+
? { 'Last-Event-ID': options.lastEventId }
|
|
56
|
+
: {}),
|
|
57
|
+
},
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
});
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
throw new TransportFailedError(
|
|
62
|
+
`SSE connection to '${options.url}' failed.`,
|
|
63
|
+
cause instanceof Error ? { cause } : {},
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!resp.ok || resp.body === null) {
|
|
67
|
+
throw new TransportFailedError(
|
|
68
|
+
`SSE endpoint '${options.url}' answered ${resp.status}${resp.body === null ? ' with no body' : ''}.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let lastEventId: string | undefined;
|
|
73
|
+
let closeFired = false;
|
|
74
|
+
const close = (reason: { code: number; reason: string; wasClean: boolean }): void => {
|
|
75
|
+
if (closeFired) return;
|
|
76
|
+
closeFired = true;
|
|
77
|
+
listeners.onClose(reason);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Consume the stream in the background: split on newlines, parse
|
|
81
|
+
// `id:` / `data:` fields, dispatch on blank lines. The `event:`
|
|
82
|
+
// name is intentionally ignored - the full frame JSON is in `data:`.
|
|
83
|
+
void (async () => {
|
|
84
|
+
const decoder = new TextDecoder();
|
|
85
|
+
let buffer = '';
|
|
86
|
+
let dataLines: string[] = [];
|
|
87
|
+
let pendingId: string | undefined;
|
|
88
|
+
|
|
89
|
+
const dispatch = (): void => {
|
|
90
|
+
if (dataLines.length === 0) {
|
|
91
|
+
pendingId = undefined;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const raw = dataLines.join('\n');
|
|
95
|
+
dataLines = [];
|
|
96
|
+
const id = pendingId;
|
|
97
|
+
pendingId = undefined;
|
|
98
|
+
let payload: unknown;
|
|
99
|
+
try {
|
|
100
|
+
payload = JSON.parse(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
// Keep-alive / non-JSON data is not a frame.
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const parsed = ServerMessageSchema.safeParse(payload);
|
|
106
|
+
if (!parsed.success) {
|
|
107
|
+
listeners.onError(
|
|
108
|
+
new InvalidServerFrameError(
|
|
109
|
+
'Server SSE frame failed schema validation.',
|
|
110
|
+
parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const frame = parsed.data;
|
|
116
|
+
const eventId = (frame as { eventId?: string }).eventId ?? id;
|
|
117
|
+
if (typeof eventId === 'string' && eventId.length > 0) lastEventId = eventId;
|
|
118
|
+
listeners.onFrame(frame);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
for await (const chunk of resp.body as unknown as AsyncIterable<Uint8Array>) {
|
|
123
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
124
|
+
for (;;) {
|
|
125
|
+
const nl = buffer.indexOf('\n');
|
|
126
|
+
if (nl === -1) break;
|
|
127
|
+
const line = buffer.slice(0, nl).replace(/\r$/, '');
|
|
128
|
+
buffer = buffer.slice(nl + 1);
|
|
129
|
+
if (line.length === 0) {
|
|
130
|
+
dispatch();
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (line.startsWith(':')) continue; // comment / keep-alive
|
|
134
|
+
if (line.startsWith('data:')) {
|
|
135
|
+
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
136
|
+
} else if (line.startsWith('id:')) {
|
|
137
|
+
pendingId = line.slice(3).trim();
|
|
138
|
+
}
|
|
139
|
+
// `event:` / `retry:` fields are intentionally ignored.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
close({ code: 1000, reason: 'stream ended', wasClean: true });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (controller.signal.aborted) {
|
|
145
|
+
close({ code: 1000, reason: 'closed by client', wasClean: true });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
close({
|
|
149
|
+
code: 1006,
|
|
150
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
151
|
+
wasClean: false,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
|
|
156
|
+
listeners.onOpen();
|
|
157
|
+
const transport: Transport = {
|
|
158
|
+
kind: 'sse',
|
|
159
|
+
url: options.url,
|
|
160
|
+
send: () => {
|
|
161
|
+
throw new TransportFailedError(
|
|
162
|
+
'The SSE transport is read-only; control-plane calls go through REST.',
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
close: () => {
|
|
166
|
+
controller.abort();
|
|
167
|
+
close({ code: 1000, reason: 'closed by client', wasClean: true });
|
|
168
|
+
},
|
|
169
|
+
get lastEventId(): string | undefined {
|
|
170
|
+
return lastEventId;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return transport;
|
|
174
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common transport contract shared by the WebSocket and SSE
|
|
3
|
+
* back-ends. The {@link GraphorinClient} talks to a transport
|
|
4
|
+
* exclusively through this interface so the two implementations are
|
|
5
|
+
* pluggable + replaceable.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ClientMessage, ServerMessage } from '@graphorin/protocol';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Discriminator used by the public client API + diagnostics.
|
|
14
|
+
*
|
|
15
|
+
* @stable
|
|
16
|
+
*/
|
|
17
|
+
export type TransportKind = 'ws' | 'sse';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stable shape consumed by every transport implementation.
|
|
21
|
+
*
|
|
22
|
+
* @stable
|
|
23
|
+
*/
|
|
24
|
+
export interface TransportOptions {
|
|
25
|
+
readonly url: string;
|
|
26
|
+
readonly auth: TransportAuth;
|
|
27
|
+
readonly fetch?: typeof fetch;
|
|
28
|
+
readonly WebSocket?: typeof WebSocket;
|
|
29
|
+
readonly EventSource?: typeof EventSource;
|
|
30
|
+
/** Per-connection identifier surfaced on diagnostics + reconnects. */
|
|
31
|
+
readonly clientId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Resume cursor sent as the `Last-Event-ID` header on (re)connect
|
|
34
|
+
* (periphery-03). The server replays only events AFTER it from the
|
|
35
|
+
* buffer - without it every SSE reconnect replays the entire
|
|
36
|
+
* buffered history. Consumed by the SSE transport; ignored by WS
|
|
37
|
+
* (whose resubscribe carries the cursor in the RPC).
|
|
38
|
+
*/
|
|
39
|
+
readonly lastEventId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Authentication strategy passed to a transport. The bearer-token
|
|
44
|
+
* shape is consumed by both transports (WS via the
|
|
45
|
+
* `Authorization` header on Node SDK clients; SSE via the
|
|
46
|
+
* `Authorization` header on every fetch); the ticket-provider shape
|
|
47
|
+
* is only used by the WS browser path (the WebSocket browser API
|
|
48
|
+
* does not allow custom headers, so the ticket rides the
|
|
49
|
+
* `Sec-WebSocket-Protocol` header).
|
|
50
|
+
*
|
|
51
|
+
* @stable
|
|
52
|
+
*/
|
|
53
|
+
export type TransportAuth =
|
|
54
|
+
| { readonly kind: 'bearer'; readonly token: string }
|
|
55
|
+
| { readonly kind: 'ticket'; readonly ticketProvider: () => Promise<string> };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Minimal listener surface the transport invokes in lifecycle order:
|
|
59
|
+
* `onOpen` ⇒ `onFrame*` (zero or more) ⇒ `onClose`. `onError` may
|
|
60
|
+
* fire at any time before `onClose`.
|
|
61
|
+
*
|
|
62
|
+
* @stable
|
|
63
|
+
*/
|
|
64
|
+
export interface TransportListeners {
|
|
65
|
+
onOpen(): void;
|
|
66
|
+
onFrame(frame: ServerMessage): void;
|
|
67
|
+
onError(error: Error): void;
|
|
68
|
+
onClose(reason: TransportCloseReason): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Discriminator returned via {@link TransportListeners.onClose}. The
|
|
73
|
+
* client uses the discriminator to decide whether to reconnect.
|
|
74
|
+
*
|
|
75
|
+
* @stable
|
|
76
|
+
*/
|
|
77
|
+
export interface TransportCloseReason {
|
|
78
|
+
readonly code: number;
|
|
79
|
+
readonly reason: string;
|
|
80
|
+
/** True when the close came from a server-side `error` frame. */
|
|
81
|
+
readonly wasClean: boolean;
|
|
82
|
+
/** Optional Graphorin reason discriminator, per `@graphorin/protocol`. */
|
|
83
|
+
readonly graphorinReason?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Active transport handle. The client owns the handle and disposes
|
|
88
|
+
* it via {@link Transport.close} on every cleanup path.
|
|
89
|
+
*
|
|
90
|
+
* @stable
|
|
91
|
+
*/
|
|
92
|
+
export interface Transport {
|
|
93
|
+
readonly kind: TransportKind;
|
|
94
|
+
readonly url: string;
|
|
95
|
+
/**
|
|
96
|
+
* Send a client → server frame. Throws when the transport is not
|
|
97
|
+
* in the open state, or when the underlying back-end does not
|
|
98
|
+
* support send (the SSE transport throws every send via
|
|
99
|
+
* `TransportFailedError` - clients
|
|
100
|
+
* should fall back to REST for control-plane operations on SSE).
|
|
101
|
+
*/
|
|
102
|
+
send(frame: ClientMessage): void;
|
|
103
|
+
close(code?: number, reason?: string): void;
|
|
104
|
+
/** Last server-issued event id observed on this connection. */
|
|
105
|
+
readonly lastEventId: string | undefined;
|
|
106
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket transport for the {@link GraphorinClient}. Speaks the
|
|
3
|
+
* `graphorin.protocol.v1` subprotocol; honours the browser ticket
|
|
4
|
+
* flow when the auth strategy is `'ticket'`.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type ClientMessage,
|
|
11
|
+
closeCodeReason,
|
|
12
|
+
formatTicketSubprotocol,
|
|
13
|
+
isErrorFrame,
|
|
14
|
+
ServerMessageSchema,
|
|
15
|
+
SUBPROTOCOL_NAME,
|
|
16
|
+
} from '@graphorin/protocol';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
AuthFailedError,
|
|
20
|
+
InvalidServerFrameError,
|
|
21
|
+
ProtocolViolationError,
|
|
22
|
+
SubprotocolMismatchError,
|
|
23
|
+
TransportFailedError,
|
|
24
|
+
} from '../errors.js';
|
|
25
|
+
import type {
|
|
26
|
+
Transport,
|
|
27
|
+
TransportCloseReason,
|
|
28
|
+
TransportListeners,
|
|
29
|
+
TransportOptions,
|
|
30
|
+
} from './types.js';
|
|
31
|
+
|
|
32
|
+
const STATE_CONNECTING = 0;
|
|
33
|
+
const STATE_OPEN = 1;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Open a WebSocket transport. Resolves once the underlying socket
|
|
37
|
+
* fires `open` (i.e. the upgrade succeeded + the subprotocol matches
|
|
38
|
+
* `SUBPROTOCOL_NAME`); rejects with a typed
|
|
39
|
+
* {@link TransportFailedError} / {@link SubprotocolMismatchError} /
|
|
40
|
+
* {@link AuthFailedError} otherwise.
|
|
41
|
+
*
|
|
42
|
+
* @stable
|
|
43
|
+
*/
|
|
44
|
+
export async function openWebSocketTransport(
|
|
45
|
+
options: TransportOptions,
|
|
46
|
+
listeners: TransportListeners,
|
|
47
|
+
): Promise<Transport> {
|
|
48
|
+
const WebSocketImpl = options.WebSocket ?? globalThis.WebSocket;
|
|
49
|
+
if (typeof WebSocketImpl !== 'function') {
|
|
50
|
+
throw new TransportFailedError(
|
|
51
|
+
'No WebSocket implementation found. Pass `WebSocket` via the transport options or run on a runtime that ships one (Node 22+, modern browsers).',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const subprotocols: string[] = [SUBPROTOCOL_NAME];
|
|
56
|
+
if (options.auth.kind === 'ticket') {
|
|
57
|
+
const ticket = await options.auth.ticketProvider();
|
|
58
|
+
subprotocols.push(formatTicketSubprotocol(ticket));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Bearer-token auth on Node SDKs is encoded as a custom header on
|
|
62
|
+
// the upgrade. The browser WebSocket API does not expose headers,
|
|
63
|
+
// so the SDK fallback path uses the ticket strategy instead. We
|
|
64
|
+
// try the 3-arg `new WebSocket(url, protocols, opts)` overload
|
|
65
|
+
// first (supported by `ws` on Node + most polyfills); if the
|
|
66
|
+
// implementation rejects it, we fall back to the 2-arg signature
|
|
67
|
+
// and rely on the server to surface auth failures via the close
|
|
68
|
+
// code (the only viable path on plain browser WebSocket).
|
|
69
|
+
let socket: WebSocket;
|
|
70
|
+
if (options.auth.kind === 'bearer') {
|
|
71
|
+
type WsCtorWithOptions = new (
|
|
72
|
+
url: string,
|
|
73
|
+
protocols: ReadonlyArray<string>,
|
|
74
|
+
options: { readonly headers: Readonly<Record<string, string>> },
|
|
75
|
+
) => WebSocket;
|
|
76
|
+
const Ctor = WebSocketImpl as unknown as WsCtorWithOptions;
|
|
77
|
+
try {
|
|
78
|
+
socket = new Ctor(options.url, subprotocols, {
|
|
79
|
+
headers: { Authorization: `Bearer ${options.auth.token}` },
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
try {
|
|
83
|
+
socket = new WebSocketImpl(options.url, subprotocols);
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
throw new TransportFailedError(
|
|
86
|
+
`Failed to construct WebSocket for '${options.url}'.`,
|
|
87
|
+
cause instanceof Error ? { cause } : {},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
try {
|
|
93
|
+
socket = new WebSocketImpl(options.url, subprotocols);
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
throw new TransportFailedError(
|
|
96
|
+
`Failed to construct WebSocket for '${options.url}'.`,
|
|
97
|
+
cause instanceof Error ? { cause } : {},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let lastEventId: string | undefined;
|
|
103
|
+
let closeFired = false;
|
|
104
|
+
let openFired = false;
|
|
105
|
+
return await new Promise<Transport>((resolve, reject) => {
|
|
106
|
+
const fail = (err: Error): void => {
|
|
107
|
+
if (!openFired) {
|
|
108
|
+
cleanup();
|
|
109
|
+
reject(err);
|
|
110
|
+
} else {
|
|
111
|
+
listeners.onError(err);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const cleanup = (): void => {
|
|
115
|
+
socket.removeEventListener('open', onOpen);
|
|
116
|
+
socket.removeEventListener('message', onMessage);
|
|
117
|
+
socket.removeEventListener('error', onError);
|
|
118
|
+
socket.removeEventListener('close', onClose);
|
|
119
|
+
};
|
|
120
|
+
const onOpen = (): void => {
|
|
121
|
+
const negotiated = socket.protocol;
|
|
122
|
+
if (negotiated !== SUBPROTOCOL_NAME) {
|
|
123
|
+
socket.close(4008, 'protocol.violation');
|
|
124
|
+
fail(new SubprotocolMismatchError(SUBPROTOCOL_NAME, negotiated || null));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
openFired = true;
|
|
128
|
+
listeners.onOpen();
|
|
129
|
+
resolve({
|
|
130
|
+
kind: 'ws',
|
|
131
|
+
url: options.url,
|
|
132
|
+
send(frame: ClientMessage): void {
|
|
133
|
+
if (socket.readyState !== STATE_OPEN) {
|
|
134
|
+
throw new TransportFailedError(
|
|
135
|
+
`Cannot send: WebSocket is in state ${socket.readyState}.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
socket.send(JSON.stringify(frame));
|
|
139
|
+
},
|
|
140
|
+
close(code?: number, reason?: string): void {
|
|
141
|
+
if (socket.readyState !== STATE_CONNECTING && socket.readyState !== STATE_OPEN) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
socket.close(code, reason);
|
|
145
|
+
},
|
|
146
|
+
get lastEventId(): string | undefined {
|
|
147
|
+
return lastEventId;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
const onMessage = (event: MessageEvent): void => {
|
|
152
|
+
const raw = typeof event.data === 'string' ? event.data : '';
|
|
153
|
+
let payload: unknown;
|
|
154
|
+
try {
|
|
155
|
+
payload = JSON.parse(raw);
|
|
156
|
+
} catch {
|
|
157
|
+
fail(new ProtocolViolationError('Server sent a non-JSON frame.'));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const parsed = ServerMessageSchema.safeParse(payload);
|
|
161
|
+
if (!parsed.success) {
|
|
162
|
+
fail(
|
|
163
|
+
new InvalidServerFrameError(
|
|
164
|
+
'Server frame failed schema validation.',
|
|
165
|
+
parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const frame = parsed.data;
|
|
171
|
+
if ('kind' in frame && frame.kind === 'event') {
|
|
172
|
+
lastEventId = frame.eventId;
|
|
173
|
+
}
|
|
174
|
+
if (isErrorFrame(frame) && frame.fatal === true) {
|
|
175
|
+
listeners.onFrame(frame);
|
|
176
|
+
if (frame.code.startsWith('auth.')) fail(new AuthFailedError(frame.message));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
listeners.onFrame(frame);
|
|
180
|
+
};
|
|
181
|
+
const onError = (): void => {
|
|
182
|
+
fail(new TransportFailedError('WebSocket error event fired.'));
|
|
183
|
+
};
|
|
184
|
+
const onClose = (event: CloseEvent): void => {
|
|
185
|
+
if (closeFired) return;
|
|
186
|
+
closeFired = true;
|
|
187
|
+
cleanup();
|
|
188
|
+
const graphorinReason = closeCodeReason(event.code);
|
|
189
|
+
const reasonRecord: TransportCloseReason = {
|
|
190
|
+
code: event.code,
|
|
191
|
+
reason: event.reason,
|
|
192
|
+
wasClean: event.wasClean,
|
|
193
|
+
...(graphorinReason !== undefined ? { graphorinReason } : {}),
|
|
194
|
+
};
|
|
195
|
+
if (!openFired) {
|
|
196
|
+
const codeReason = closeCodeReason(event.code);
|
|
197
|
+
if (codeReason?.startsWith('auth.') === true) {
|
|
198
|
+
fail(new AuthFailedError(event.reason || codeReason));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
fail(new TransportFailedError(event.reason || 'WebSocket closed before open.'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
listeners.onClose(reasonRecord);
|
|
205
|
+
};
|
|
206
|
+
socket.addEventListener('open', onOpen);
|
|
207
|
+
socket.addEventListener('message', onMessage);
|
|
208
|
+
socket.addEventListener('error', onError);
|
|
209
|
+
socket.addEventListener('close', onClose);
|
|
210
|
+
});
|
|
211
|
+
}
|