@ironflow/browser 0.27.0 → 0.29.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/dist/client.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Singleton client for browser-based real-time interactions with Ironflow.
5
5
  */
6
- import { NotConfiguredError, IronflowError, RunFailedError, RunCancelledError, UnauthenticatedError, EnterpriseRequiredError, UnauthorizedError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, HEADERS, TriggerResponseSchema, TriggerSyncResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, peelProjectionEnvelope, webhookVerifyConfigToWire, webhookVerifyConfigFromWire, } from "@ironflow/core";
6
+ import { NotConfiguredError, IronflowError, RunFailedError, RunCancelledError, UnauthenticatedError, EnterpriseRequiredError, UnauthorizedError, ValidationError, createLogger, createNoopLogger, DEFAULT_TIMEOUTS, HEADERS, TriggerResponseSchema, TriggerSyncResponseSchema, RunResponseSchema, ListRunsResponseSchema, RunStatusSchema, ErrorResponseSchema, safeJsonParse, patterns, peelProjectionEnvelope, webhookVerifyConfigToWire, webhookGraceToWire, webhookSourceFromWire, webhookDeliveryFromWire, } from "@ironflow/core";
7
7
  import { mergeConfig } from "./config.js";
8
8
  import { SubscriptionManager, } from "./subscription.js";
9
9
  import { createWebSocketTransport } from "./transport/websocket.js";
@@ -12,6 +12,28 @@ import { filterWaitStreamFrames } from "./projection-stream.js";
12
12
  import { BrowserKVClient } from "./kv.js";
13
13
  import { BrowserConfigClient } from "./config-client.js";
14
14
  import { createAgentsNamespace } from "./agents/index.js";
15
+ /**
16
+ * Key for the offline queue's replay entry point.
17
+ *
18
+ * Not exported from the package root, so it is unreachable outside this module
19
+ * pair — the closest thing to package-private TypeScript offers.
20
+ */
21
+ export const REPLAY_QUEUED_WRITE = Symbol("ironflow.replayQueuedWrite");
22
+ /**
23
+ * Whether an HTTP status is worth trying again.
24
+ *
25
+ * 5xx is the obvious half. 429 is the half that is easy to miss: a rate limit
26
+ * is a "later", not a "never", and a caller that treats it as permanent gives
27
+ * up at exactly the moment backing off would have worked.
28
+ *
29
+ * This matters most for the offline write queue (ADR 0053), whose drain after
30
+ * a long offline stretch is a burst of writes — the single most likely way to
31
+ * earn a 429. Classifying it as permanent would send those writes to the dead
32
+ * letter store precisely when the queue was doing its job.
33
+ */
34
+ function isRetryableStatus(status) {
35
+ return status >= 500 || status === 429;
36
+ }
15
37
  /**
16
38
  * Ironflow browser client singleton
17
39
  */
@@ -89,6 +111,34 @@ class IronflowClient {
89
111
  transport: this.config.transport,
90
112
  });
91
113
  }
114
+ /**
115
+ * Replace the credentials without tearing anything down.
116
+ *
117
+ * `configure()` cannot be used for this: it calls `cleanup()`, which destroys
118
+ * the transport, every subscription, and — for an offline client — the
119
+ * drainer mid-flight. Refreshing an expired token must not cost you all of
120
+ * that.
121
+ *
122
+ * Every request path reads `config.auth` at send time (`request()`,
123
+ * `streamRequest()`, `restRequest()` all build their `Authorization` header
124
+ * per call), so the next request picks this up with no further work. An
125
+ * already-open subscription keeps the credentials it connected with until it
126
+ * reconnects.
127
+ */
128
+ setAuth(auth) {
129
+ this.ensureConfigured();
130
+ this.config.auth = auth;
131
+ this.logger.debug("Auth updated");
132
+ }
133
+ /**
134
+ * The configured logger.
135
+ *
136
+ * @internal Lets sibling modules (the offline queue) honour the app's logging
137
+ * configuration instead of writing straight to the console.
138
+ */
139
+ getLogger() {
140
+ return this.logger;
141
+ }
92
142
  /**
93
143
  * Check if the client is configured
94
144
  */
@@ -301,14 +351,6 @@ class IronflowClient {
301
351
  const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/CancelRun", { id: runId, reason });
302
352
  return this.mapRunResponse(response);
303
353
  }
304
- /**
305
- * Retry a failed run
306
- */
307
- async retryRun(runId, fromStep) {
308
- this.ensureConfigured();
309
- const response = await this.request(RunResponseSchema, "POST", "/ironflow.v1.IronflowService/RetryRun", { id: runId, fromStep });
310
- return this.mapRunResponse(response);
311
- }
312
354
  /**
313
355
  * Patch a step's output (hot patching)
314
356
  */
@@ -1088,6 +1130,7 @@ class IronflowClient {
1088
1130
  return {
1089
1131
  columns: response.columns ?? [],
1090
1132
  rows: (response.rows ?? []).map((r) => r.values),
1133
+ typedRows: (response.rows ?? []).map((r) => r.typedValues ?? []),
1091
1134
  totalCount: Number(response.totalCount ?? 0),
1092
1135
  };
1093
1136
  }
@@ -1824,47 +1867,113 @@ class IronflowClient {
1824
1867
  * ```
1825
1868
  */
1826
1869
  webhooks = {
1827
- /** Create a new webhook source */
1870
+ /**
1871
+ * Create a new webhook source.
1872
+ *
1873
+ * The returned `ingestToken` is the ONLY copy (ADR 0048) — the server
1874
+ * keeps a hash, so a source whose token is dropped can never receive a
1875
+ * delivery.
1876
+ */
1828
1877
  create: async (input) => {
1829
1878
  this.ensureConfigured();
1830
- const response = await this.streamRequest("/ironflow.v1.WebhookService/CreateWebhookSource", {
1831
- id: input.id,
1879
+ return webhookSourceFromWire(await this.streamRequest("/ironflow.v1.WebhookService/CreateWebhookSource", {
1880
+ name: input.name,
1832
1881
  event_prefix: input.eventPrefix,
1833
1882
  verify_header: input.verifyHeader ?? "",
1834
1883
  verify_algorithm: input.verifyAlgorithm ?? "",
1835
1884
  verify_secret: input.verifySecret ?? "",
1836
1885
  verify_config: webhookVerifyConfigToWire(input.verifyConfig),
1837
1886
  metadata: input.metadata,
1838
- });
1839
- return {
1840
- id: response.id,
1841
- eventPrefix: response.event_prefix,
1842
- verifyHeader: response.verify_header,
1843
- verifyAlgorithm: response.verify_algorithm,
1844
- sourceType: response.source_type,
1845
- metadata: response.metadata,
1846
- // ADR 0048: raw token returned only here; the server keeps a hash.
1847
- ingestToken: response.ingest_token,
1848
- ingestTokenPrefix: response.ingest_token_prefix,
1849
- verifyConfig: webhookVerifyConfigFromWire(response.verify_config),
1850
- createdAt: response.created_at,
1851
- updatedAt: response.updated_at,
1852
- };
1887
+ }));
1888
+ },
1889
+ /** Fetch a single webhook source by ID. */
1890
+ getSource: async (id) => {
1891
+ this.ensureConfigured();
1892
+ return webhookSourceFromWire(await this.streamRequest("/ironflow.v1.WebhookService/GetWebhookSource", { id }));
1893
+ },
1894
+ /**
1895
+ * Replace the editable fields on a webhook source.
1896
+ *
1897
+ * FULL-REPLACE, not patch — every omitted field is cleared server-side.
1898
+ * Fetch with `getSource` first and copy across what you are not changing;
1899
+ * see {@link UpdateWebhookSourceInput}.
1900
+ */
1901
+ updateSource: async (input) => {
1902
+ this.ensureConfigured();
1903
+ return webhookSourceFromWire(await this.streamRequest("/ironflow.v1.WebhookService/UpdateWebhookSource", {
1904
+ id: input.id,
1905
+ name: input.name,
1906
+ verify_header: input.verifyHeader ?? "",
1907
+ verify_algorithm: input.verifyAlgorithm ?? "",
1908
+ verify_config: webhookVerifyConfigToWire(input.verifyConfig),
1909
+ metadata: input.metadata,
1910
+ ...(input.expectedUpdatedAt
1911
+ ? { expected_updated_at: input.expectedUpdatedAt }
1912
+ : {}),
1913
+ }));
1914
+ },
1915
+ /**
1916
+ * Rotate a source's verify secret (ADR 0024), keeping the prior secret
1917
+ * verifying as `prev` for the grace window.
1918
+ */
1919
+ rotateSecret: async (input) => {
1920
+ this.ensureConfigured();
1921
+ return webhookSourceFromWire(await this.streamRequest("/ironflow.v1.WebhookService/RotateWebhookSecret", {
1922
+ id: input.id,
1923
+ verify_secret: input.verifySecret,
1924
+ // Tri-state + range-validated; see webhookGraceToWire.
1925
+ ...webhookGraceToWire(input.graceSeconds),
1926
+ ...(input.expectedUpdatedAt
1927
+ ? { expected_updated_at: input.expectedUpdatedAt }
1928
+ : {}),
1929
+ }));
1930
+ },
1931
+ /**
1932
+ * Force-expire the previous secret slot, ending a rotation grace window
1933
+ * early. Idempotent.
1934
+ */
1935
+ expireSecretPrev: async (id, expectedUpdatedAt) => {
1936
+ this.ensureConfigured();
1937
+ return webhookSourceFromWire(await this.streamRequest("/ironflow.v1.WebhookService/ExpireWebhookSecretPrev", {
1938
+ id,
1939
+ ...(expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}),
1940
+ }));
1941
+ },
1942
+ /**
1943
+ * Stop verifying signatures on a source. The prior secret is preserved as
1944
+ * `prev` for the grace window; after it lapses the source ingests
1945
+ * unsigned.
1946
+ */
1947
+ disableSignatureVerification: async (input) => {
1948
+ this.ensureConfigured();
1949
+ return webhookSourceFromWire(await this.streamRequest("/ironflow.v1.WebhookService/DisableWebhookSignatureVerification", {
1950
+ id: input.id,
1951
+ // Tri-state + range-validated; see webhookGraceToWire.
1952
+ ...webhookGraceToWire(input.graceSeconds),
1953
+ ...(input.expectedUpdatedAt
1954
+ ? { expected_updated_at: input.expectedUpdatedAt }
1955
+ : {}),
1956
+ }));
1957
+ },
1958
+ /**
1959
+ * Rotate a source's per-source ingest token (ADR 0048).
1960
+ *
1961
+ * No grace window — the previous token stops working immediately, so
1962
+ * update the provider's URL as soon as this returns. `ingestToken` on the
1963
+ * result is the only copy.
1964
+ */
1965
+ rotateIngestToken: async (id, expectedUpdatedAt) => {
1966
+ this.ensureConfigured();
1967
+ return webhookSourceFromWire(await this.streamRequest("/ironflow.v1.WebhookService/RotateWebhookIngestToken", {
1968
+ id,
1969
+ ...(expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}),
1970
+ }));
1853
1971
  },
1854
1972
  /** List all registered webhook sources */
1855
1973
  listSources: async () => {
1856
1974
  this.ensureConfigured();
1857
1975
  const response = await this.streamRequest("/ironflow.v1.WebhookService/ListWebhookSources", { limit: 0, offset: 0 });
1858
- return (response.sources ?? []).map((s) => ({
1859
- id: s.id,
1860
- eventPrefix: s.event_prefix,
1861
- verifyHeader: s.verify_header,
1862
- verifyAlgorithm: s.verify_algorithm,
1863
- sourceType: s.source_type,
1864
- metadata: s.metadata,
1865
- createdAt: s.created_at,
1866
- updatedAt: s.updated_at,
1867
- }));
1976
+ return (response.sources ?? []).map(webhookSourceFromWire);
1868
1977
  },
1869
1978
  /** Delete a webhook source by ID */
1870
1979
  deleteSource: async (id) => {
@@ -1881,15 +1990,7 @@ class IronflowClient {
1881
1990
  offset: opts?.offset ?? 0,
1882
1991
  });
1883
1992
  return {
1884
- deliveries: (response.deliveries ?? []).map((d) => ({
1885
- id: d.id,
1886
- sourceId: d.source_id,
1887
- externalId: d.external_id,
1888
- status: d.status,
1889
- eventId: d.event_id,
1890
- error: d.error,
1891
- createdAt: d.created_at,
1892
- })),
1993
+ deliveries: (response.deliveries ?? []).map(webhookDeliveryFromWire),
1893
1994
  totalCount: response.total_count ?? 0,
1894
1995
  };
1895
1996
  },
@@ -2009,28 +2110,112 @@ class IronflowClient {
2009
2110
  };
2010
2111
  document.addEventListener("visibilitychange", this.visibilityHandler);
2011
2112
  }
2012
- async request(schema, method, path, body) {
2113
+ /**
2114
+ * Shared HTTP shell for every REST/Connect call the client makes.
2115
+ *
2116
+ * `send` owns the transport concerns; `handle` owns how one response becomes
2117
+ * a `T`. Splitting there is what lets three callers with very different
2118
+ * response handling share one implementation:
2119
+ *
2120
+ * caller ──> send(method, path, body, handle)
2121
+ * │
2122
+ * ├─ headers: Content-Type (only when a body is sent),
2123
+ * │ X-Ironflow-Environment,
2124
+ * │ Authorization (apiKey wins over token)
2125
+ * ├─ fetch ──> handle(response) ──> T
2126
+ * │ └─ may throw IronflowError ──┐
2127
+ * ├─ catch: AbortError -> TIMEOUT │ rethrown
2128
+ * │ anything else -> REQUEST_FAILED │ as-is
2129
+ * └─ finally: clearTimeout <───────────────────┘
2130
+ *
2131
+ * Two details are load-bearing and must not be "tidied":
2132
+ *
2133
+ * 1. The abort timer is cleared only *after* `handle` returns, so a slow
2134
+ * parse of a large body can still trip the timeout. That matches the
2135
+ * behaviour of the three helpers this replaces.
2136
+ * 2. `handle` runs inside the try, so errors it throws pass through the
2137
+ * catch below. Every typed error in this SDK (`ValidationError`,
2138
+ * `UnauthenticatedError`, `EnterpriseRequiredError`, `UnauthorizedError`)
2139
+ * extends `IronflowError`, so the single `instanceof` guard covers all of
2140
+ * them and they are rethrown untouched.
2141
+ */
2142
+ async send(method, path, body, handle) {
2013
2143
  const url = `${this.config.serverUrl}${path}`;
2014
2144
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
2015
2145
  const controller = new AbortController();
2016
2146
  const timeoutId = setTimeout(() => controller.abort(), timeout);
2017
2147
  try {
2018
2148
  const headers = {
2019
- "Content-Type": "application/json",
2020
2149
  [HEADERS.ENVIRONMENT]: this.config.environment,
2021
2150
  };
2151
+ if (body !== undefined) {
2152
+ headers["Content-Type"] = "application/json";
2153
+ }
2022
2154
  if (this.config.auth?.apiKey) {
2023
2155
  headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
2024
2156
  }
2025
2157
  else if (this.config.auth?.token) {
2026
2158
  headers["Authorization"] = `Bearer ${this.config.auth.token}`;
2027
2159
  }
2028
- const response = await fetch(url, {
2160
+ const fetchOptions = {
2029
2161
  method,
2030
2162
  headers,
2031
- body: JSON.stringify(body),
2032
2163
  signal: controller.signal,
2164
+ };
2165
+ if (body !== undefined) {
2166
+ fetchOptions.body = JSON.stringify(body);
2167
+ }
2168
+ const response = await fetch(url, fetchOptions);
2169
+ try {
2170
+ return await handle(response);
2171
+ }
2172
+ catch (error) {
2173
+ if (error instanceof IronflowError)
2174
+ throw error;
2175
+ if (error instanceof Error && error.name === "AbortError")
2176
+ throw error;
2177
+ // The request itself succeeded; the BODY could not be understood.
2178
+ // Retrying cannot fix a server that returns unparseable bytes, and
2179
+ // marking it retryable would stall the offline queue at this write
2180
+ // indefinitely (it blocks on the head of a strict FIFO).
2181
+ throw new IronflowError(`${method} ${path} returned an unparseable response: ${error instanceof Error ? error.message : String(error)}`, {
2182
+ code: "RESPONSE_UNPARSEABLE",
2183
+ status: response.status,
2184
+ retryable: false,
2185
+ cause: error instanceof Error ? error : undefined,
2186
+ });
2187
+ }
2188
+ }
2189
+ catch (error) {
2190
+ if (error instanceof IronflowError) {
2191
+ throw error;
2192
+ }
2193
+ if (error instanceof Error && error.name === "AbortError") {
2194
+ throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, {
2195
+ code: "TIMEOUT",
2196
+ retryable: true,
2197
+ });
2198
+ }
2199
+ throw new IronflowError(error instanceof Error
2200
+ ? `${method} ${path} failed: ${error.message}`
2201
+ : `${method} ${path} failed`, {
2202
+ code: "REQUEST_FAILED",
2203
+ retryable: true,
2204
+ cause: error instanceof Error ? error : undefined,
2033
2205
  });
2206
+ }
2207
+ finally {
2208
+ clearTimeout(timeoutId);
2209
+ }
2210
+ }
2211
+ /**
2212
+ * Connect-style request whose response is validated against a zod schema.
2213
+ *
2214
+ * Reads the body exactly once and reuses the text for both the error path
2215
+ * and the success parse.
2216
+ */
2217
+ async request(schema, method, path, body) {
2218
+ return this.send(method, path, body, async (response) => {
2034
2219
  const responseBody = await response.text();
2035
2220
  if (!response.ok) {
2036
2221
  const errorResult = ErrorResponseSchema.safeParse(safeJsonParse(responseBody));
@@ -2039,7 +2224,8 @@ class IronflowClient {
2039
2224
  : { message: responseBody };
2040
2225
  throw new IronflowError(errorData.message ?? `Request failed: ${response.status}`, {
2041
2226
  code: errorData.code ?? `HTTP_${response.status}`,
2042
- retryable: response.status >= 500,
2227
+ status: response.status,
2228
+ retryable: isRetryableStatus(response.status),
2043
2229
  });
2044
2230
  }
2045
2231
  const parsed = safeJsonParse(responseBody);
@@ -2054,78 +2240,61 @@ class IronflowClient {
2054
2240
  throw new ValidationError(`Invalid response from server: ${issues}`);
2055
2241
  }
2056
2242
  return result.data;
2243
+ });
2244
+ }
2245
+ /**
2246
+ * Replay a write that the offline queue stored earlier (ADR 0053).
2247
+ *
2248
+ * Keyed by a module-private symbol rather than a name. An `@internal` JSDoc
2249
+ * tag does nothing without `stripInternal`, which this repo does not set, so
2250
+ * a named method would ship in the published .d.ts — and this one is an
2251
+ * arbitrary-path authenticated POST, which is not something to hand out.
2252
+ * `index.ts` does not re-export the symbol, so no consumer can reach it.
2253
+ *
2254
+ * Takes the serialised body the queue persisted and routes it through the
2255
+ * ordinary request path, so a replayed write gets exactly the same headers,
2256
+ * timeout and retry classification as a live one — including 429 being
2257
+ * retryable, which the drainer depends on.
2258
+ */
2259
+ async [REPLAY_QUEUED_WRITE](path, body) {
2260
+ this.ensureConfigured();
2261
+ // Parsed here rather than inline in the call, and as a NON-retryable coded
2262
+ // error: a bare SyntaxError is not an IronflowError, and the drainer treats
2263
+ // anything else as a network fault worth retrying. Since the queue is strict
2264
+ // FIFO, an unreadable record at the head would then block every write behind
2265
+ // it until retention expired. Dead-lettering quarantines just the one.
2266
+ let parsed;
2267
+ try {
2268
+ parsed = JSON.parse(body);
2057
2269
  }
2058
2270
  catch (error) {
2059
- if (error instanceof IronflowError || error instanceof ValidationError) {
2060
- throw error;
2061
- }
2062
- if (error instanceof Error && error.name === "AbortError") {
2063
- throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, {
2064
- code: "TIMEOUT",
2065
- retryable: true,
2066
- });
2067
- }
2068
- throw new IronflowError(error instanceof Error
2069
- ? `${method} ${path} failed: ${error.message}`
2070
- : `${method} ${path} failed`, {
2071
- code: "REQUEST_FAILED",
2072
- retryable: true,
2073
- cause: error instanceof Error ? error : undefined,
2074
- });
2075
- }
2076
- finally {
2077
- clearTimeout(timeoutId);
2271
+ throw new IronflowError(`Queued write body is not valid JSON and can never be sent: ${error instanceof Error ? error.message : String(error)}`, { code: "QUEUE_BODY_CORRUPT", retryable: false });
2078
2272
  }
2273
+ const response = await this.streamRequest(path, parsed);
2274
+ return {
2275
+ eventId: response.eventId,
2276
+ entityVersion: response.entityVersion === undefined
2277
+ ? undefined
2278
+ : Number(response.entityVersion),
2279
+ };
2079
2280
  }
2281
+ /**
2282
+ * Connect-style POST returning the raw decoded JSON, no schema validation.
2283
+ *
2284
+ * Only reads the body on the error path.
2285
+ */
2080
2286
  async streamRequest(path, body) {
2081
- const url = `${this.config.serverUrl}${path}`;
2082
- const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
2083
- const controller = new AbortController();
2084
- const timeoutId = setTimeout(() => controller.abort(), timeout);
2085
- try {
2086
- const headers = {
2087
- "Content-Type": "application/json",
2088
- [HEADERS.ENVIRONMENT]: this.config.environment,
2089
- };
2090
- if (this.config.auth?.apiKey) {
2091
- headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
2092
- }
2093
- else if (this.config.auth?.token) {
2094
- headers["Authorization"] = `Bearer ${this.config.auth.token}`;
2095
- }
2096
- const response = await fetch(url, {
2097
- method: "POST",
2098
- headers,
2099
- body: JSON.stringify(body),
2100
- signal: controller.signal,
2101
- });
2287
+ return this.send("POST", path, body, async (response) => {
2102
2288
  if (!response.ok) {
2103
2289
  const errorBody = safeJsonParse(await response.text());
2104
2290
  throw new IronflowError(errorBody?.message ?? `Request failed: ${response.status}`, {
2105
2291
  code: errorBody?.code ?? `HTTP_${response.status}`,
2106
- retryable: response.status >= 500,
2292
+ status: response.status,
2293
+ retryable: isRetryableStatus(response.status),
2107
2294
  });
2108
2295
  }
2109
2296
  return response.json();
2110
- }
2111
- catch (error) {
2112
- if (error instanceof IronflowError) {
2113
- throw error;
2114
- }
2115
- if (error instanceof Error && error.name === "AbortError") {
2116
- throw new IronflowError(`Request timeout after ${timeout}ms for POST ${path}`, { code: "TIMEOUT", retryable: true });
2117
- }
2118
- throw new IronflowError(error instanceof Error
2119
- ? `POST ${path} failed: ${error.message}`
2120
- : `POST ${path} failed`, {
2121
- code: "REQUEST_FAILED",
2122
- retryable: true,
2123
- cause: error instanceof Error ? error : undefined,
2124
- });
2125
- }
2126
- finally {
2127
- clearTimeout(timeoutId);
2128
- }
2297
+ });
2129
2298
  }
2130
2299
  /**
2131
2300
  * REST request helper for auth management endpoints.
@@ -2134,34 +2303,13 @@ class IronflowClient {
2134
2303
  * - 401 → UnauthenticatedError
2135
2304
  * - 402 → EnterpriseRequiredError
2136
2305
  * - 403 → UnauthorizedError
2306
+ *
2307
+ * Note the error message template differs from the two Connect helpers
2308
+ * above ("Request failed with status N" vs "Request failed: N"); both are
2309
+ * pinned by tests, so pick deliberately if you ever unify them.
2137
2310
  */
2138
2311
  async restRequest(method, path, body) {
2139
- const url = `${this.config.serverUrl}${path}`;
2140
- const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
2141
- const controller = new AbortController();
2142
- const timeoutId = setTimeout(() => controller.abort(), timeout);
2143
- try {
2144
- const headers = {
2145
- [HEADERS.ENVIRONMENT]: this.config.environment,
2146
- };
2147
- if (body !== undefined) {
2148
- headers["Content-Type"] = "application/json";
2149
- }
2150
- if (this.config.auth?.apiKey) {
2151
- headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
2152
- }
2153
- else if (this.config.auth?.token) {
2154
- headers["Authorization"] = `Bearer ${this.config.auth.token}`;
2155
- }
2156
- const fetchOptions = {
2157
- method,
2158
- headers,
2159
- signal: controller.signal,
2160
- };
2161
- if (body !== undefined) {
2162
- fetchOptions.body = JSON.stringify(body);
2163
- }
2164
- const response = await fetch(url, fetchOptions);
2312
+ return this.send(method, path, body, async (response) => {
2165
2313
  // Handle 204 No Content
2166
2314
  if (response.status === 204) {
2167
2315
  return undefined;
@@ -2188,33 +2336,13 @@ class IronflowClient {
2188
2336
  default:
2189
2337
  throw new IronflowError(errorMessage, {
2190
2338
  code: `HTTP_${response.status}`,
2191
- retryable: response.status >= 500,
2339
+ status: response.status,
2340
+ retryable: isRetryableStatus(response.status),
2192
2341
  });
2193
2342
  }
2194
2343
  }
2195
2344
  return response.json();
2196
- }
2197
- catch (error) {
2198
- if (error instanceof IronflowError ||
2199
- error instanceof UnauthenticatedError ||
2200
- error instanceof EnterpriseRequiredError ||
2201
- error instanceof UnauthorizedError) {
2202
- throw error;
2203
- }
2204
- if (error instanceof Error && error.name === "AbortError") {
2205
- throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, { code: "TIMEOUT", retryable: true });
2206
- }
2207
- throw new IronflowError(error instanceof Error
2208
- ? `${method} ${path} failed: ${error.message}`
2209
- : `${method} ${path} failed`, {
2210
- code: "REQUEST_FAILED",
2211
- retryable: true,
2212
- cause: error instanceof Error ? error : undefined,
2213
- });
2214
- }
2215
- finally {
2216
- clearTimeout(timeoutId);
2217
- }
2345
+ });
2218
2346
  }
2219
2347
  mapRunResponse(response) {
2220
2348
  // ConnectRPC returns proto enum strings like "RUN_STATUS_COMPLETED" — normalize to "completed"