@smithers-orchestrator/gateway-client 0.18.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/gateway-client",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Browser-first client SDK for the Smithers Gateway RPC and event stream APIs",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,7 +20,7 @@
20
20
  "src/"
21
21
  ],
22
22
  "dependencies": {
23
- "@smithers-orchestrator/gateway": "0.18.0"
23
+ "@smithers-orchestrator/gateway": "0.19.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -15,6 +15,13 @@ type StreamRunEventPayload = {
15
15
  payload?: unknown;
16
16
  };
17
17
 
18
+ type StreamDevToolsEventPayload = {
19
+ streamId?: string;
20
+ runId?: string;
21
+ event?: unknown;
22
+ error?: unknown;
23
+ };
24
+
18
25
  declare global {
19
26
  var __SMITHERS_GATEWAY_UI__: GatewayUiBootConfig | undefined;
20
27
  }
@@ -26,25 +33,34 @@ function defaultBaseUrl() {
26
33
  return "http://127.0.0.1:7331";
27
34
  }
28
35
 
36
+ function isUnixWebSocketUrl(baseUrl: string) {
37
+ return /^ws\+unix:/i.test(baseUrl);
38
+ }
39
+
29
40
  function normalizeBaseUrl(baseUrl: string) {
41
+ if (isUnixWebSocketUrl(baseUrl)) {
42
+ return baseUrl;
43
+ }
30
44
  return baseUrl.replace(/\/+$/, "");
31
45
  }
32
46
 
33
47
  function toWebSocketUrl(baseUrl: string, wsPath = "/") {
48
+ if (isUnixWebSocketUrl(baseUrl)) {
49
+ const url = new URL(baseUrl);
50
+ const socketPath = url.pathname.split(":", 1)[0];
51
+ const path = wsPath.startsWith("/") ? wsPath : `/${wsPath}`;
52
+ url.pathname = `${socketPath}:${path}`;
53
+ url.search = "";
54
+ return url.toString();
55
+ }
34
56
  const url = new URL(wsPath, baseUrl);
35
57
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
36
58
  return url.toString();
37
59
  }
38
60
 
39
- function randomId(method: string) {
40
- const cryptoApi = globalThis.crypto;
41
- const suffix = typeof cryptoApi?.randomUUID === "function"
42
- ? cryptoApi.randomUUID()
43
- : Math.random().toString(36).slice(2);
44
- return `${method}-${suffix}`;
45
- }
61
+ const unavailableFetch = (() => Promise.reject(new Error("fetch is not available in this environment."))) as unknown as typeof fetch;
46
62
 
47
- function headersFromOptions(options: SmithersGatewayClientOptions) {
63
+ function headersFromOptions(options: Pick<SmithersGatewayClientOptions, "headers" | "token">) {
48
64
  const headers = new Headers(options.headers);
49
65
  headers.set("content-type", "application/json");
50
66
  if (options.token) {
@@ -53,6 +69,44 @@ function headersFromOptions(options: SmithersGatewayClientOptions) {
53
69
  return headers;
54
70
  }
55
71
 
72
+ function gatewayHttpError(method: string, status: number, message = `Gateway HTTP ${status}`) {
73
+ return new GatewayRpcError({
74
+ method,
75
+ status,
76
+ code: "HTTP_ERROR",
77
+ message,
78
+ });
79
+ }
80
+
81
+ function invalidGatewayResponse(method: string, status: number | undefined, details?: unknown) {
82
+ return new GatewayRpcError({
83
+ method,
84
+ status,
85
+ code: "INVALID_GATEWAY_RESPONSE",
86
+ message: "Gateway returned an invalid RPC response frame.",
87
+ details,
88
+ });
89
+ }
90
+
91
+ function isObject(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === "object" && value !== null && !Array.isArray(value);
93
+ }
94
+
95
+ function isGatewayResponseFrame(value: unknown): value is GatewayResponseFrame {
96
+ if (!isObject(value)) {
97
+ return false;
98
+ }
99
+ if (value.type !== "res" || typeof value.id !== "string" || typeof value.ok !== "boolean") {
100
+ return false;
101
+ }
102
+ if (value.ok === true) {
103
+ return "payload" in value;
104
+ }
105
+ return isObject(value.error) &&
106
+ typeof value.error.code === "string" &&
107
+ typeof value.error.message === "string";
108
+ }
109
+
56
110
  function rpcError(frame: Extract<GatewayResponseFrame, { ok: false }>, method: string, status?: number) {
57
111
  return new GatewayRpcError({
58
112
  method,
@@ -69,7 +123,7 @@ export class SmithersGatewayClient {
69
123
  readonly baseUrl: string;
70
124
  readonly token?: string;
71
125
  readonly fetchImpl: typeof fetch;
72
- readonly WebSocketImpl: typeof WebSocket;
126
+ readonly WebSocketImpl: typeof WebSocket | undefined;
73
127
  readonly headers: HeadersInit | undefined;
74
128
  readonly client: Required<NonNullable<SmithersGatewayClientOptions["client"]>>;
75
129
  readonly boot: GatewayUiBootConfig | undefined;
@@ -79,7 +133,11 @@ export class SmithersGatewayClient {
79
133
  this.baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultBaseUrl());
80
134
  this.token = options.token;
81
135
  this.headers = options.headers;
82
- this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
136
+ this.fetchImpl = options.fetch ?? (
137
+ typeof globalThis.fetch === "function"
138
+ ? globalThis.fetch.bind(globalThis)
139
+ : unavailableFetch
140
+ );
83
141
  this.WebSocketImpl = options.WebSocket ?? globalThis.WebSocket;
84
142
  this.client = {
85
143
  id: options.client?.id ?? "smithers-gateway-client",
@@ -103,14 +161,20 @@ export class SmithersGatewayClient {
103
161
  body: JSON.stringify(params ?? {}),
104
162
  signal: options.signal,
105
163
  });
106
- const frame = (await response.json()) as GatewayResponseFrame;
107
- if (!response.ok && !("ok" in frame)) {
108
- throw new GatewayRpcError({
109
- method,
110
- status: response.status,
111
- code: "HTTP_ERROR",
112
- message: `Gateway HTTP ${response.status}`,
113
- });
164
+ let frame: unknown;
165
+ try {
166
+ frame = await response.json();
167
+ } catch {
168
+ if (response.ok) {
169
+ throw invalidGatewayResponse(method, response.status);
170
+ }
171
+ throw gatewayHttpError(method, response.status);
172
+ }
173
+ if (!isGatewayResponseFrame(frame)) {
174
+ if (!response.ok) {
175
+ throw gatewayHttpError(method, response.status);
176
+ }
177
+ throw invalidGatewayResponse(method, response.status, frame);
114
178
  }
115
179
  if (!frame.ok) {
116
180
  throw rpcError(frame, method, response.status);
@@ -122,6 +186,9 @@ export class SmithersGatewayClient {
122
186
  if (!this.WebSocketImpl) {
123
187
  throw new Error("WebSocket is not available in this environment.");
124
188
  }
189
+ if (options.signal?.aborted) {
190
+ throw new Error("Gateway WebSocket open aborted.");
191
+ }
125
192
  const ws = new this.WebSocketImpl(toWebSocketUrl(this.baseUrl, this.boot?.wsPath));
126
193
  await new Promise<void>((resolve, reject) => {
127
194
  const onOpen = () => {
@@ -132,26 +199,33 @@ export class SmithersGatewayClient {
132
199
  cleanup();
133
200
  reject(new Error("Gateway WebSocket failed to open."));
134
201
  };
202
+ const onAbort = () => {
203
+ cleanup();
204
+ ws.close();
205
+ reject(new Error("Gateway WebSocket open aborted."));
206
+ };
135
207
  const cleanup = () => {
136
208
  ws.removeEventListener("open", onOpen);
137
209
  ws.removeEventListener("error", onError);
210
+ options.signal?.removeEventListener("abort", onAbort);
138
211
  };
139
212
  ws.addEventListener("open", onOpen);
140
213
  ws.addEventListener("error", onError);
141
- options.signal?.addEventListener("abort", () => {
142
- cleanup();
143
- ws.close();
144
- reject(new Error("Gateway WebSocket open aborted."));
145
- }, { once: true });
214
+ options.signal?.addEventListener("abort", onAbort, { once: true });
146
215
  });
147
216
  const connection = new SmithersGatewayConnection(ws);
148
- await connection.requestRaw("connect", {
149
- minProtocol: 1,
150
- maxProtocol: 1,
151
- client: this.client,
152
- ...(this.token ? { auth: { token: this.token } } : {}),
153
- ...(options.subscribe ? { subscribe: options.subscribe } : {}),
154
- });
217
+ try {
218
+ await connection.requestRaw("connect", {
219
+ minProtocol: 1,
220
+ maxProtocol: 1,
221
+ client: this.client,
222
+ ...(this.token ? { auth: { token: this.token } } : {}),
223
+ ...(options.subscribe ? { subscribe: options.subscribe } : {}),
224
+ });
225
+ } catch (error) {
226
+ connection.close();
227
+ throw error;
228
+ }
155
229
  return connection;
156
230
  }
157
231
 
@@ -181,6 +255,32 @@ export class SmithersGatewayClient {
181
255
  }
182
256
  }
183
257
 
258
+ async *streamDevTools(
259
+ params: GatewayRpcParams<"streamDevTools">,
260
+ options: { signal?: AbortSignal } = {},
261
+ ): AsyncGenerator<GatewayEventFrame<StreamDevToolsEventPayload>> {
262
+ const connection = await this.connect({ subscribe: [params.runId], signal: options.signal });
263
+ try {
264
+ const subscribed = await connection.request("streamDevTools", params);
265
+ if (!isObject(subscribed) || typeof subscribed.streamId !== "string") {
266
+ throw invalidGatewayResponse("streamDevTools", undefined, subscribed);
267
+ }
268
+ for await (const frame of connection.events(options.signal)) {
269
+ if (
270
+ (frame.event === "devtools.event" || frame.event === "devtools.error") &&
271
+ typeof frame.payload === "object" &&
272
+ frame.payload !== null &&
273
+ "streamId" in frame.payload &&
274
+ frame.payload.streamId === subscribed.streamId
275
+ ) {
276
+ yield frame as GatewayEventFrame<StreamDevToolsEventPayload>;
277
+ }
278
+ }
279
+ } finally {
280
+ connection.close();
281
+ }
282
+ }
283
+
184
284
  launchRun(params: GatewayRpcParams<"launchRun">) {
185
285
  return this.rpc("launchRun", params);
186
286
  }
@@ -193,6 +293,14 @@ export class SmithersGatewayClient {
193
293
  return this.rpc("cancelRun", params);
194
294
  }
195
295
 
296
+ hijackRun(params: GatewayRpcParams<"hijackRun">) {
297
+ return this.rpc("hijackRun", params);
298
+ }
299
+
300
+ rewindRun(params: GatewayRpcParams<"rewindRun">) {
301
+ return this.rpc("rewindRun", params);
302
+ }
303
+
196
304
  submitApproval(params: GatewayRpcParams<"submitApproval">) {
197
305
  return this.rpc("submitApproval", params);
198
306
  }
@@ -224,4 +332,20 @@ export class SmithersGatewayClient {
224
332
  getNodeDiff(params: GatewayRpcParams<"getNodeDiff">) {
225
333
  return this.rpc("getNodeDiff", params);
226
334
  }
335
+
336
+ cronList(params: GatewayRpcParams<"cronList"> = {}) {
337
+ return this.rpc("cronList", params);
338
+ }
339
+
340
+ cronCreate(params: GatewayRpcParams<"cronCreate">) {
341
+ return this.rpc("cronCreate", params);
342
+ }
343
+
344
+ cronDelete(params: GatewayRpcParams<"cronDelete">) {
345
+ return this.rpc("cronDelete", params);
346
+ }
347
+
348
+ cronRun(params: GatewayRpcParams<"cronRun">) {
349
+ return this.rpc("cronRun", params);
350
+ }
227
351
  }
@@ -34,6 +34,42 @@ function frameError(frame: Extract<GatewayResponseFrame, { ok: false }>, method:
34
34
  });
35
35
  }
36
36
 
37
+ function isObject(value: unknown): value is Record<string, unknown> {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+
41
+ function invalidFrameError(details?: unknown) {
42
+ return new GatewayRpcError({
43
+ method: "websocket",
44
+ code: "INVALID_GATEWAY_RESPONSE",
45
+ message: "Gateway returned an invalid WebSocket frame.",
46
+ details,
47
+ });
48
+ }
49
+
50
+ function isGatewayResponseFrame(value: unknown): value is GatewayResponseFrame {
51
+ if (!isObject(value)) {
52
+ return false;
53
+ }
54
+ if (value.type !== "res" || typeof value.id !== "string" || typeof value.ok !== "boolean") {
55
+ return false;
56
+ }
57
+ if (value.ok === true) {
58
+ return "payload" in value;
59
+ }
60
+ return isObject(value.error) &&
61
+ typeof value.error.code === "string" &&
62
+ typeof value.error.message === "string";
63
+ }
64
+
65
+ function isGatewayEventFrame(value: unknown): value is GatewayEventFrame {
66
+ return isObject(value) &&
67
+ value.type === "event" &&
68
+ typeof value.event === "string" &&
69
+ typeof value.seq === "number" &&
70
+ typeof value.stateVersion === "number";
71
+ }
72
+
37
73
  export class SmithersGatewayConnection {
38
74
  readonly ws: WebSocket;
39
75
  readonly pending = new Map<string, PendingRequest>();
@@ -50,12 +86,12 @@ export class SmithersGatewayConnection {
50
86
  this.push({ kind: "error", error: new Error("Gateway WebSocket error") });
51
87
  });
52
88
  ws.addEventListener("close", () => {
89
+ const alreadyClosed = this.closed;
53
90
  this.closed = true;
54
- for (const pending of this.pending.values()) {
55
- pending.reject(new Error("Gateway WebSocket closed"));
91
+ this.rejectPending(new Error("Gateway WebSocket closed"));
92
+ if (!alreadyClosed) {
93
+ this.push({ kind: "close" });
56
94
  }
57
- this.pending.clear();
58
- this.push({ kind: "close" });
59
95
  });
60
96
  }
61
97
 
@@ -63,6 +99,9 @@ export class SmithersGatewayConnection {
63
99
  method: Method,
64
100
  params: GatewayRpcParams<Method>,
65
101
  ): Promise<GatewayRpcPayload<Method>> {
102
+ if (this.closed) {
103
+ return Promise.reject(new Error("Gateway WebSocket is closed"));
104
+ }
66
105
  const id = randomId(method);
67
106
  const frame = { type: "req", id, method, params };
68
107
  return new Promise((resolve, reject) => {
@@ -71,21 +110,38 @@ export class SmithersGatewayConnection {
71
110
  resolve: (payload) => resolve(payload as GatewayRpcPayload<Method>),
72
111
  reject,
73
112
  });
74
- this.ws.send(JSON.stringify(frame));
113
+ try {
114
+ this.ws.send(JSON.stringify(frame));
115
+ } catch (cause) {
116
+ this.pending.delete(id);
117
+ reject(cause instanceof Error ? cause : new Error(String(cause)));
118
+ }
75
119
  });
76
120
  }
77
121
 
78
122
  requestRaw(method: string, params?: unknown): Promise<unknown> {
123
+ if (this.closed) {
124
+ return Promise.reject(new Error("Gateway WebSocket is closed"));
125
+ }
79
126
  const id = randomId(method);
80
127
  const frame = { type: "req", id, method, params };
81
128
  return new Promise((resolve, reject) => {
82
129
  this.pending.set(id, { method, resolve, reject });
83
- this.ws.send(JSON.stringify(frame));
130
+ try {
131
+ this.ws.send(JSON.stringify(frame));
132
+ } catch (cause) {
133
+ this.pending.delete(id);
134
+ reject(cause instanceof Error ? cause : new Error(String(cause)));
135
+ }
84
136
  });
85
137
  }
86
138
 
87
139
  async *events(signal?: AbortSignal): AsyncGenerator<GatewayEventFrame> {
88
140
  const abort = () => this.close();
141
+ if (signal?.aborted) {
142
+ this.close();
143
+ return;
144
+ }
89
145
  signal?.addEventListener("abort", abort, { once: true });
90
146
  try {
91
147
  while (!this.closed || this.queue.length > 0) {
@@ -108,13 +164,21 @@ export class SmithersGatewayConnection {
108
164
  return;
109
165
  }
110
166
  this.closed = true;
167
+ this.rejectPending(new Error("Gateway WebSocket closed"));
168
+ this.push({ kind: "close" });
111
169
  this.ws.close();
112
170
  }
113
171
 
114
172
  private handleMessage(raw: unknown) {
115
173
  const text = typeof raw === "string" ? raw : String(raw);
116
- const frame = JSON.parse(text) as GatewayResponseFrame | GatewayEventFrame;
117
- if (frame.type === "res") {
174
+ let frame: unknown;
175
+ try {
176
+ frame = JSON.parse(text);
177
+ } catch {
178
+ this.push({ kind: "error", error: invalidFrameError(text) });
179
+ return;
180
+ }
181
+ if (isGatewayResponseFrame(frame)) {
118
182
  const pending = this.pending.get(frame.id);
119
183
  if (!pending) {
120
184
  return;
@@ -127,9 +191,19 @@ export class SmithersGatewayConnection {
127
191
  pending.reject(frameError(frame, pending.method));
128
192
  return;
129
193
  }
130
- if (frame.type === "event") {
194
+ if (isObject(frame) && frame.type === "res" && typeof frame.id === "string") {
195
+ const pending = this.pending.get(frame.id);
196
+ if (pending) {
197
+ this.pending.delete(frame.id);
198
+ pending.reject(invalidFrameError(frame));
199
+ }
200
+ return;
201
+ }
202
+ if (isGatewayEventFrame(frame)) {
131
203
  this.push({ kind: "event", frame });
204
+ return;
132
205
  }
206
+ this.push({ kind: "error", error: invalidFrameError(frame) });
133
207
  }
134
208
 
135
209
  private push(event: QueuedEvent) {
@@ -151,4 +225,11 @@ export class SmithersGatewayConnection {
151
225
  }
152
226
  return this.queue.shift();
153
227
  }
228
+
229
+ private rejectPending(error: Error) {
230
+ for (const pending of this.pending.values()) {
231
+ pending.reject(error);
232
+ }
233
+ this.pending.clear();
234
+ }
154
235
  }