@graphorin/client 0.6.0 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"reconnect.js","names":[],"sources":["../src/reconnect.ts"],"sourcesContent":["/**\n * Pure-functional reconnect-backoff helper. Encapsulated in its own\n * module so the {@link GraphorinClient} stays free of timing\n * heuristics - and so tests can drive the policy with a deterministic\n * RNG.\n *\n * Algorithm: exponential backoff with full-jitter\n * (`delay = random(0, min(maxMs, baseMs * 2^attempt))`). The\n * implementation matches the AWS Architecture Blog \"exponential\n * backoff and jitter\" reference but is otherwise an original\n * formulation.\n *\n * @packageDocumentation\n */\n\n/**\n * Stable shape consumed by {@link computeBackoffMs}.\n *\n * @stable\n */\nexport interface BackoffPolicy {\n /** Initial slot in milliseconds. Default `500`. */\n readonly baseMs?: number;\n /** Cap on every individual sleep. Default `30_000`. */\n readonly maxMs?: number;\n /**\n * Hard cap on the number of attempts. The client surfaces a\n * `TransportFailedError` once exceeded. Default `Infinity`.\n */\n readonly maxAttempts?: number;\n /**\n * Optional injection seam used by tests; defaults to `Math.random`.\n */\n readonly random?: () => number;\n}\n\n/**\n * Compute the number of milliseconds to sleep before the\n * `attempt`-th reconnect (1-indexed). Returns `null` when the policy\n * has been exhausted (`attempt > maxAttempts`).\n *\n * @stable\n */\nexport function computeBackoffMs(attempt: number, policy: BackoffPolicy = {}): number | null {\n if (!Number.isFinite(attempt) || attempt < 1) {\n throw new RangeError('computeBackoffMs: attempt must be a positive integer.');\n }\n const baseMs = policy.baseMs ?? 500;\n const maxMs = policy.maxMs ?? 30_000;\n const maxAttempts = policy.maxAttempts ?? Number.POSITIVE_INFINITY;\n const random = policy.random ?? Math.random;\n if (attempt > maxAttempts) return null;\n // Exponential growth with full jitter, clamped to maxMs.\n const exp = Math.min(maxMs, baseMs * 2 ** Math.min(attempt - 1, 30));\n return Math.floor(random() * exp);\n}\n\n/**\n * Resolve when the requested number of milliseconds elapsed, or\n * reject (with a {@link DOMException}-style abort error) when the\n * supplied {@link AbortSignal} fires first.\n *\n * @stable\n */\nexport function sleep(durationMs: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(abortError(signal.reason));\n }\n return new Promise<void>((resolve, reject) => {\n const timeout = setTimeout(() => {\n cleanup();\n resolve();\n }, durationMs);\n const onAbort = (): void => {\n cleanup();\n reject(abortError(signal?.reason));\n };\n const cleanup = (): void => {\n clearTimeout(timeout);\n signal?.removeEventListener('abort', onAbort);\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction abortError(reason: unknown): Error {\n if (reason instanceof Error) return reason;\n const err = new Error('Aborted.');\n err.name = 'AbortError';\n return err;\n}\n"],"mappings":";;;;;;;;AA2CA,SAAgB,iBAAiB,SAAiB,SAAwB,EAAE,EAAiB;AAC3F,KAAI,CAAC,OAAO,SAAS,QAAQ,IAAI,UAAU,EACzC,OAAM,IAAI,WAAW,wDAAwD;CAE/E,MAAM,SAAS,OAAO,UAAU;CAChC,MAAM,QAAQ,OAAO,SAAS;CAC9B,MAAM,cAAc,OAAO,eAAe,OAAO;CACjD,MAAM,SAAS,OAAO,UAAU,KAAK;AACrC,KAAI,UAAU,YAAa,QAAO;CAElC,MAAM,MAAM,KAAK,IAAI,OAAO,SAAS,KAAK,KAAK,IAAI,UAAU,GAAG,GAAG,CAAC;AACpE,QAAO,KAAK,MAAM,QAAQ,GAAG,IAAI;;;;;;;;;AAUnC,SAAgB,MAAM,YAAoB,QAAqC;AAC7E,KAAI,QAAQ,QACV,QAAO,QAAQ,OAAO,WAAW,OAAO,OAAO,CAAC;AAElD,QAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,MAAM,UAAU,iBAAiB;AAC/B,YAAS;AACT,YAAS;KACR,WAAW;EACd,MAAM,gBAAsB;AAC1B,YAAS;AACT,UAAO,WAAW,QAAQ,OAAO,CAAC;;EAEpC,MAAM,gBAAsB;AAC1B,gBAAa,QAAQ;AACrB,WAAQ,oBAAoB,SAAS,QAAQ;;AAE/C,UAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM,CAAC;GAC1D;;AAGJ,SAAS,WAAW,QAAwB;AAC1C,KAAI,kBAAkB,MAAO,QAAO;CACpC,MAAM,sBAAM,IAAI,MAAM,WAAW;AACjC,KAAI,OAAO;AACX,QAAO"}
1
+ {"version":3,"file":"reconnect.js","names":[],"sources":["../src/reconnect.ts"],"sourcesContent":["/**\n * Pure-functional reconnect-backoff helper. Encapsulated in its own\n * module so the `GraphorinClient` stays free of timing\n * heuristics - and so tests can drive the policy with a deterministic\n * RNG.\n *\n * Algorithm: exponential backoff with full-jitter\n * (`delay = random(0, min(maxMs, baseMs * 2^attempt))`). The\n * implementation matches the AWS Architecture Blog \"exponential\n * backoff and jitter\" reference but is otherwise an original\n * formulation.\n *\n * @packageDocumentation\n */\n\n/**\n * Stable shape consumed by {@link computeBackoffMs}.\n *\n * @stable\n */\nexport interface BackoffPolicy {\n /** Initial slot in milliseconds. Default `500`. */\n readonly baseMs?: number;\n /** Cap on every individual sleep. Default `30_000`. */\n readonly maxMs?: number;\n /**\n * Hard cap on the number of attempts. The client surfaces a\n * `TransportFailedError` once exceeded. Default `Infinity`.\n */\n readonly maxAttempts?: number;\n /**\n * Optional injection seam used by tests; defaults to `Math.random`.\n */\n readonly random?: () => number;\n}\n\n/**\n * Compute the number of milliseconds to sleep before the\n * `attempt`-th reconnect (1-indexed). Returns `null` when the policy\n * has been exhausted (`attempt > maxAttempts`).\n *\n * @stable\n */\nexport function computeBackoffMs(attempt: number, policy: BackoffPolicy = {}): number | null {\n if (!Number.isFinite(attempt) || attempt < 1) {\n throw new RangeError('computeBackoffMs: attempt must be a positive integer.');\n }\n const baseMs = policy.baseMs ?? 500;\n const maxMs = policy.maxMs ?? 30_000;\n const maxAttempts = policy.maxAttempts ?? Number.POSITIVE_INFINITY;\n const random = policy.random ?? Math.random;\n if (attempt > maxAttempts) return null;\n // Exponential growth with full jitter, clamped to maxMs.\n const exp = Math.min(maxMs, baseMs * 2 ** Math.min(attempt - 1, 30));\n return Math.floor(random() * exp);\n}\n\n/**\n * Resolve when the requested number of milliseconds elapsed, or\n * reject (with a `DOMException`-style abort error) when the\n * supplied `AbortSignal` fires first.\n *\n * @stable\n */\nexport function sleep(durationMs: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(abortError(signal.reason));\n }\n return new Promise<void>((resolve, reject) => {\n const timeout = setTimeout(() => {\n cleanup();\n resolve();\n }, durationMs);\n const onAbort = (): void => {\n cleanup();\n reject(abortError(signal?.reason));\n };\n const cleanup = (): void => {\n clearTimeout(timeout);\n signal?.removeEventListener('abort', onAbort);\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction abortError(reason: unknown): Error {\n if (reason instanceof Error) return reason;\n const err = new Error('Aborted.');\n err.name = 'AbortError';\n return err;\n}\n"],"mappings":";;;;;;;;AA2CA,SAAgB,iBAAiB,SAAiB,SAAwB,EAAE,EAAiB;AAC3F,KAAI,CAAC,OAAO,SAAS,QAAQ,IAAI,UAAU,EACzC,OAAM,IAAI,WAAW,wDAAwD;CAE/E,MAAM,SAAS,OAAO,UAAU;CAChC,MAAM,QAAQ,OAAO,SAAS;CAC9B,MAAM,cAAc,OAAO,eAAe,OAAO;CACjD,MAAM,SAAS,OAAO,UAAU,KAAK;AACrC,KAAI,UAAU,YAAa,QAAO;CAElC,MAAM,MAAM,KAAK,IAAI,OAAO,SAAS,KAAK,KAAK,IAAI,UAAU,GAAG,GAAG,CAAC;AACpE,QAAO,KAAK,MAAM,QAAQ,GAAG,IAAI;;;;;;;;;AAUnC,SAAgB,MAAM,YAAoB,QAAqC;AAC7E,KAAI,QAAQ,QACV,QAAO,QAAQ,OAAO,WAAW,OAAO,OAAO,CAAC;AAElD,QAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,MAAM,UAAU,iBAAiB;AAC/B,YAAS;AACT,YAAS;KACR,WAAW;EACd,MAAM,gBAAsB;AAC1B,YAAS;AACT,UAAO,WAAW,QAAQ,OAAO,CAAC;;EAEpC,MAAM,gBAAsB;AAC1B,gBAAa,QAAQ;AACrB,WAAQ,oBAAoB,SAAS,QAAQ;;AAE/C,UAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM,CAAC;GAC1D;;AAGJ,SAAS,WAAW,QAAwB;AAC1C,KAAI,kBAAkB,MAAO,QAAO;CACpC,MAAM,sBAAM,IAAI,MAAM,WAAW;AACjC,KAAI,OAAO;AACX,QAAO"}
@@ -88,7 +88,7 @@ interface Transport {
88
88
  * Send a client → server frame. Throws when the transport is not
89
89
  * in the open state, or when the underlying back-end does not
90
90
  * support send (the SSE transport throws every send via
91
- * {@link import('../errors.js').TransportFailedError} - clients
91
+ * `TransportFailedError` - clients
92
92
  * should fall back to REST for control-plane operations on SSE).
93
93
  */
94
94
  send(frame: ClientMessage): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphorin/client",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Reference TypeScript client for the Graphorin standalone server. Wraps the WebSocket subprotocol `graphorin.protocol.v1` (with the optional Server-Sent Events fallback for proxy-restricted environments) behind an ergonomic `GraphorinClient` class: `connect()`, `subscribe({ target, id })` returning an async-iterable subscription, `cancel(runId, opts)`, `resume(runId, directive)`, `ping()`, `disconnect()`. Handles the browser ticket flow (single-use ticket attached as a `ticket.<value>` `Sec-WebSocket-Protocol` token), exponential-backoff reconnect with `lastEventId` resume against the server replay buffer, and Zod-validated frame parsing on both directions. Browser-friendly: zero Node-only dependencies; the only runtime dependencies are `@graphorin/protocol` and `zod`. Created and maintained by Oleksiy Stepurenko.",
5
5
  "license": "MIT",
6
6
  "author": "Oleksiy Stepurenko",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "type": "module",
29
29
  "engines": {
30
- "node": ">=22.0.0"
30
+ "node": ">=22.12.0"
31
31
  },
32
32
  "main": "./dist/index.js",
33
33
  "module": "./dist/index.js",
@@ -36,35 +36,36 @@
36
36
  "exports": {
37
37
  ".": {
38
38
  "types": "./dist/index.d.ts",
39
- "import": "./dist/index.js"
39
+ "default": "./dist/index.js"
40
40
  },
41
41
  "./client": {
42
42
  "types": "./dist/graphorin-client.d.ts",
43
- "import": "./dist/graphorin-client.js"
43
+ "default": "./dist/graphorin-client.js"
44
44
  },
45
45
  "./transport": {
46
46
  "types": "./dist/transport/index.d.ts",
47
- "import": "./dist/transport/index.js"
47
+ "default": "./dist/transport/index.js"
48
48
  },
49
49
  "./reconnect": {
50
50
  "types": "./dist/reconnect.d.ts",
51
- "import": "./dist/reconnect.js"
51
+ "default": "./dist/reconnect.js"
52
52
  },
53
53
  "./errors": {
54
54
  "types": "./dist/errors.d.ts",
55
- "import": "./dist/errors.js"
55
+ "default": "./dist/errors.js"
56
56
  },
57
57
  "./package.json": "./package.json"
58
58
  },
59
59
  "files": [
60
60
  "dist",
61
+ "src",
61
62
  "README.md",
62
63
  "CHANGELOG.md",
63
64
  "LICENSE"
64
65
  ],
65
66
  "dependencies": {
66
67
  "zod": "^3.25.0",
67
- "@graphorin/protocol": "0.6.0"
68
+ "@graphorin/protocol": "0.7.0"
68
69
  },
69
70
  "publishConfig": {
70
71
  "access": "public",
@@ -73,9 +74,9 @@
73
74
  "devDependencies": {
74
75
  "@types/ws": "^8.18.1",
75
76
  "ws": "^8.20.1",
76
- "@graphorin/security": "0.6.0",
77
- "@graphorin/server": "0.6.0",
78
- "@graphorin/store-sqlite": "0.6.0"
77
+ "@graphorin/security": "0.7.0",
78
+ "@graphorin/server": "0.7.0",
79
+ "@graphorin/store-sqlite": "0.7.0"
79
80
  },
80
81
  "scripts": {
81
82
  "build": "tsdown",
package/src/errors.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Typed error hierarchy surfaced by `@graphorin/client`. Every error
3
+ * class extends the JavaScript built-in `Error` and exposes a stable
4
+ * `kind` discriminator so consumers can pattern-match without
5
+ * relying on `instanceof` (which behaves badly across module-system
6
+ * boundaries when the package is dual-loaded).
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import { RPC_ERROR_CODES } from '@graphorin/protocol';
12
+
13
+ /**
14
+ * IP-19: map a JSON-RPC error code from a server `RpcFailure` frame to the
15
+ * client's discriminated {@link GraphorinClientErrorKind}, so a rate-limited
16
+ * or scope-denied RPC is distinguishable from a genuine protocol violation.
17
+ */
18
+ export function kindForRpcCode(code: number): GraphorinClientErrorKind {
19
+ switch (code) {
20
+ case RPC_ERROR_CODES.RATE_LIMITED:
21
+ return 'rate-limited';
22
+ case RPC_ERROR_CODES.SCOPE_DENIED:
23
+ return 'scope-denied';
24
+ case RPC_ERROR_CODES.AUTH_REQUIRED:
25
+ case RPC_ERROR_CODES.AUTH_INVALID:
26
+ return 'auth-failed';
27
+ case RPC_ERROR_CODES.RUN_NOT_FOUND:
28
+ return 'run-not-found';
29
+ case RPC_ERROR_CODES.SUBSCRIPTION_NOT_FOUND:
30
+ return 'subscription-not-found';
31
+ case RPC_ERROR_CODES.INTERNAL_ERROR:
32
+ return 'server-error';
33
+ default:
34
+ // INVALID_REQUEST / INVALID_PARAMS / METHOD_NOT_FOUND / PROTOCOL_VIOLATION
35
+ // and any unknown code stay a protocol violation.
36
+ return 'protocol-violation';
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Discriminator union of every error kind raised by the client.
42
+ *
43
+ * @stable
44
+ */
45
+ export type GraphorinClientErrorKind =
46
+ | 'client-not-connected'
47
+ | 'transport-failed'
48
+ | 'subprotocol-mismatch'
49
+ | 'auth-failed'
50
+ | 'protocol-violation'
51
+ | 'subscription-not-found'
52
+ // IP-19: discriminated RPC-failure kinds so callers can branch on the
53
+ // server's error class instead of pattern-matching the message string.
54
+ | 'rate-limited'
55
+ | 'scope-denied'
56
+ | 'run-not-found'
57
+ | 'server-error'
58
+ | 'aborted'
59
+ /**
60
+ * W-152: the per-subscription client buffer hit
61
+ * `subscriptionQueueLimit` because the `for await` consumer fell
62
+ * behind the event flow; the subscription is closed with this typed
63
+ * error instead of growing the heap or silently dropping frames.
64
+ */
65
+ | 'flow-overflow'
66
+ | 'invalid-server-frame';
67
+
68
+ /**
69
+ * Base class for every error raised by `@graphorin/client`. Carries a
70
+ * stable {@link GraphorinClientErrorKind} discriminator and an
71
+ * optional `cause` chain.
72
+ *
73
+ * @stable
74
+ */
75
+ export class GraphorinClientError extends Error {
76
+ readonly kind: GraphorinClientErrorKind;
77
+
78
+ constructor(kind: GraphorinClientErrorKind, message: string, options: ErrorOptions = {}) {
79
+ super(message, options);
80
+ this.kind = kind;
81
+ this.name = 'GraphorinClientError';
82
+ }
83
+ }
84
+
85
+ /** @stable */
86
+ export class ClientNotConnectedError extends GraphorinClientError {
87
+ constructor(message = 'GraphorinClient is not connected. Call connect() first.') {
88
+ super('client-not-connected', message);
89
+ this.name = 'ClientNotConnectedError';
90
+ }
91
+ }
92
+
93
+ /** @stable */
94
+ export class TransportFailedError extends GraphorinClientError {
95
+ readonly code: number | undefined;
96
+
97
+ constructor(message: string, options: ErrorOptions & { readonly code?: number } = {}) {
98
+ super('transport-failed', message, options);
99
+ this.name = 'TransportFailedError';
100
+ this.code = options.code;
101
+ }
102
+ }
103
+
104
+ /** @stable */
105
+ export class SubprotocolMismatchError extends GraphorinClientError {
106
+ readonly expected: string;
107
+ readonly actual: string | null;
108
+
109
+ constructor(expected: string, actual: string | null) {
110
+ super(
111
+ 'subprotocol-mismatch',
112
+ `Server selected subprotocol '${actual ?? '<none>'}'; expected '${expected}'.`,
113
+ );
114
+ this.name = 'SubprotocolMismatchError';
115
+ this.expected = expected;
116
+ this.actual = actual;
117
+ }
118
+ }
119
+
120
+ /** @stable */
121
+ export class AuthFailedError extends GraphorinClientError {
122
+ constructor(message = 'Authentication failed. Re-mint a token or request a new ticket.') {
123
+ super('auth-failed', message);
124
+ this.name = 'AuthFailedError';
125
+ }
126
+ }
127
+
128
+ /** @stable */
129
+ export class ProtocolViolationError extends GraphorinClientError {
130
+ constructor(message: string, options: ErrorOptions = {}) {
131
+ super('protocol-violation', message, options);
132
+ this.name = 'ProtocolViolationError';
133
+ }
134
+ }
135
+
136
+ /** @stable */
137
+ export class SubscriptionNotFoundError extends GraphorinClientError {
138
+ readonly subscriptionId: string;
139
+
140
+ constructor(subscriptionId: string) {
141
+ super(
142
+ 'subscription-not-found',
143
+ `Subscription '${subscriptionId}' is not active on this client.`,
144
+ );
145
+ this.name = 'SubscriptionNotFoundError';
146
+ this.subscriptionId = subscriptionId;
147
+ }
148
+ }
149
+
150
+ /** @stable */
151
+ export class ClientAbortedError extends GraphorinClientError {
152
+ constructor(message = 'Operation aborted before completion.') {
153
+ super('aborted', message);
154
+ this.name = 'ClientAbortedError';
155
+ }
156
+ }
157
+
158
+ /** @stable */
159
+ export class InvalidServerFrameError extends GraphorinClientError {
160
+ readonly issues: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>;
161
+
162
+ constructor(
163
+ message: string,
164
+ issues: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>,
165
+ ) {
166
+ super('invalid-server-frame', message);
167
+ this.name = 'InvalidServerFrameError';
168
+ this.issues = issues;
169
+ }
170
+ }