@ironflow/browser 0.26.0 → 0.28.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
@@ -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 0052), 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
  */
@@ -2009,28 +2051,112 @@ class IronflowClient {
2009
2051
  };
2010
2052
  document.addEventListener("visibilitychange", this.visibilityHandler);
2011
2053
  }
2012
- async request(schema, method, path, body) {
2054
+ /**
2055
+ * Shared HTTP shell for every REST/Connect call the client makes.
2056
+ *
2057
+ * `send` owns the transport concerns; `handle` owns how one response becomes
2058
+ * a `T`. Splitting there is what lets three callers with very different
2059
+ * response handling share one implementation:
2060
+ *
2061
+ * caller ──> send(method, path, body, handle)
2062
+ * │
2063
+ * ├─ headers: Content-Type (only when a body is sent),
2064
+ * │ X-Ironflow-Environment,
2065
+ * │ Authorization (apiKey wins over token)
2066
+ * ├─ fetch ──> handle(response) ──> T
2067
+ * │ └─ may throw IronflowError ──┐
2068
+ * ├─ catch: AbortError -> TIMEOUT │ rethrown
2069
+ * │ anything else -> REQUEST_FAILED │ as-is
2070
+ * └─ finally: clearTimeout <───────────────────┘
2071
+ *
2072
+ * Two details are load-bearing and must not be "tidied":
2073
+ *
2074
+ * 1. The abort timer is cleared only *after* `handle` returns, so a slow
2075
+ * parse of a large body can still trip the timeout. That matches the
2076
+ * behaviour of the three helpers this replaces.
2077
+ * 2. `handle` runs inside the try, so errors it throws pass through the
2078
+ * catch below. Every typed error in this SDK (`ValidationError`,
2079
+ * `UnauthenticatedError`, `EnterpriseRequiredError`, `UnauthorizedError`)
2080
+ * extends `IronflowError`, so the single `instanceof` guard covers all of
2081
+ * them and they are rethrown untouched.
2082
+ */
2083
+ async send(method, path, body, handle) {
2013
2084
  const url = `${this.config.serverUrl}${path}`;
2014
2085
  const timeout = this.config.timeout ?? DEFAULT_TIMEOUTS.CLIENT;
2015
2086
  const controller = new AbortController();
2016
2087
  const timeoutId = setTimeout(() => controller.abort(), timeout);
2017
2088
  try {
2018
2089
  const headers = {
2019
- "Content-Type": "application/json",
2020
2090
  [HEADERS.ENVIRONMENT]: this.config.environment,
2021
2091
  };
2092
+ if (body !== undefined) {
2093
+ headers["Content-Type"] = "application/json";
2094
+ }
2022
2095
  if (this.config.auth?.apiKey) {
2023
2096
  headers["Authorization"] = `Bearer ${this.config.auth.apiKey}`;
2024
2097
  }
2025
2098
  else if (this.config.auth?.token) {
2026
2099
  headers["Authorization"] = `Bearer ${this.config.auth.token}`;
2027
2100
  }
2028
- const response = await fetch(url, {
2101
+ const fetchOptions = {
2029
2102
  method,
2030
2103
  headers,
2031
- body: JSON.stringify(body),
2032
2104
  signal: controller.signal,
2105
+ };
2106
+ if (body !== undefined) {
2107
+ fetchOptions.body = JSON.stringify(body);
2108
+ }
2109
+ const response = await fetch(url, fetchOptions);
2110
+ try {
2111
+ return await handle(response);
2112
+ }
2113
+ catch (error) {
2114
+ if (error instanceof IronflowError)
2115
+ throw error;
2116
+ if (error instanceof Error && error.name === "AbortError")
2117
+ throw error;
2118
+ // The request itself succeeded; the BODY could not be understood.
2119
+ // Retrying cannot fix a server that returns unparseable bytes, and
2120
+ // marking it retryable would stall the offline queue at this write
2121
+ // indefinitely (it blocks on the head of a strict FIFO).
2122
+ throw new IronflowError(`${method} ${path} returned an unparseable response: ${error instanceof Error ? error.message : String(error)}`, {
2123
+ code: "RESPONSE_UNPARSEABLE",
2124
+ status: response.status,
2125
+ retryable: false,
2126
+ cause: error instanceof Error ? error : undefined,
2127
+ });
2128
+ }
2129
+ }
2130
+ catch (error) {
2131
+ if (error instanceof IronflowError) {
2132
+ throw error;
2133
+ }
2134
+ if (error instanceof Error && error.name === "AbortError") {
2135
+ throw new IronflowError(`Request timeout after ${timeout}ms for ${method} ${path}`, {
2136
+ code: "TIMEOUT",
2137
+ retryable: true,
2138
+ });
2139
+ }
2140
+ throw new IronflowError(error instanceof Error
2141
+ ? `${method} ${path} failed: ${error.message}`
2142
+ : `${method} ${path} failed`, {
2143
+ code: "REQUEST_FAILED",
2144
+ retryable: true,
2145
+ cause: error instanceof Error ? error : undefined,
2033
2146
  });
2147
+ }
2148
+ finally {
2149
+ clearTimeout(timeoutId);
2150
+ }
2151
+ }
2152
+ /**
2153
+ * Connect-style request whose response is validated against a zod schema.
2154
+ *
2155
+ * Reads the body exactly once and reuses the text for both the error path
2156
+ * and the success parse.
2157
+ */
2158
+ async request(schema, method, path, body) {
2159
+ return this.send(method, path, body, async (response) => {
2034
2160
  const responseBody = await response.text();
2035
2161
  if (!response.ok) {
2036
2162
  const errorResult = ErrorResponseSchema.safeParse(safeJsonParse(responseBody));
@@ -2039,7 +2165,8 @@ class IronflowClient {
2039
2165
  : { message: responseBody };
2040
2166
  throw new IronflowError(errorData.message ?? `Request failed: ${response.status}`, {
2041
2167
  code: errorData.code ?? `HTTP_${response.status}`,
2042
- retryable: response.status >= 500,
2168
+ status: response.status,
2169
+ retryable: isRetryableStatus(response.status),
2043
2170
  });
2044
2171
  }
2045
2172
  const parsed = safeJsonParse(responseBody);
@@ -2054,78 +2181,61 @@ class IronflowClient {
2054
2181
  throw new ValidationError(`Invalid response from server: ${issues}`);
2055
2182
  }
2056
2183
  return result.data;
2184
+ });
2185
+ }
2186
+ /**
2187
+ * Replay a write that the offline queue stored earlier (ADR 0052).
2188
+ *
2189
+ * Keyed by a module-private symbol rather than a name. An `@internal` JSDoc
2190
+ * tag does nothing without `stripInternal`, which this repo does not set, so
2191
+ * a named method would ship in the published .d.ts — and this one is an
2192
+ * arbitrary-path authenticated POST, which is not something to hand out.
2193
+ * `index.ts` does not re-export the symbol, so no consumer can reach it.
2194
+ *
2195
+ * Takes the serialised body the queue persisted and routes it through the
2196
+ * ordinary request path, so a replayed write gets exactly the same headers,
2197
+ * timeout and retry classification as a live one — including 429 being
2198
+ * retryable, which the drainer depends on.
2199
+ */
2200
+ async [REPLAY_QUEUED_WRITE](path, body) {
2201
+ this.ensureConfigured();
2202
+ // Parsed here rather than inline in the call, and as a NON-retryable coded
2203
+ // error: a bare SyntaxError is not an IronflowError, and the drainer treats
2204
+ // anything else as a network fault worth retrying. Since the queue is strict
2205
+ // FIFO, an unreadable record at the head would then block every write behind
2206
+ // it until retention expired. Dead-lettering quarantines just the one.
2207
+ let parsed;
2208
+ try {
2209
+ parsed = JSON.parse(body);
2057
2210
  }
2058
2211
  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);
2212
+ 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
2213
  }
2214
+ const response = await this.streamRequest(path, parsed);
2215
+ return {
2216
+ eventId: response.eventId,
2217
+ entityVersion: response.entityVersion === undefined
2218
+ ? undefined
2219
+ : Number(response.entityVersion),
2220
+ };
2079
2221
  }
2222
+ /**
2223
+ * Connect-style POST returning the raw decoded JSON, no schema validation.
2224
+ *
2225
+ * Only reads the body on the error path.
2226
+ */
2080
2227
  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
- });
2228
+ return this.send("POST", path, body, async (response) => {
2102
2229
  if (!response.ok) {
2103
2230
  const errorBody = safeJsonParse(await response.text());
2104
2231
  throw new IronflowError(errorBody?.message ?? `Request failed: ${response.status}`, {
2105
2232
  code: errorBody?.code ?? `HTTP_${response.status}`,
2106
- retryable: response.status >= 500,
2233
+ status: response.status,
2234
+ retryable: isRetryableStatus(response.status),
2107
2235
  });
2108
2236
  }
2109
2237
  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
- }
2238
+ });
2129
2239
  }
2130
2240
  /**
2131
2241
  * REST request helper for auth management endpoints.
@@ -2134,34 +2244,13 @@ class IronflowClient {
2134
2244
  * - 401 → UnauthenticatedError
2135
2245
  * - 402 → EnterpriseRequiredError
2136
2246
  * - 403 → UnauthorizedError
2247
+ *
2248
+ * Note the error message template differs from the two Connect helpers
2249
+ * above ("Request failed with status N" vs "Request failed: N"); both are
2250
+ * pinned by tests, so pick deliberately if you ever unify them.
2137
2251
  */
2138
2252
  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);
2253
+ return this.send(method, path, body, async (response) => {
2165
2254
  // Handle 204 No Content
2166
2255
  if (response.status === 204) {
2167
2256
  return undefined;
@@ -2188,33 +2277,13 @@ class IronflowClient {
2188
2277
  default:
2189
2278
  throw new IronflowError(errorMessage, {
2190
2279
  code: `HTTP_${response.status}`,
2191
- retryable: response.status >= 500,
2280
+ status: response.status,
2281
+ retryable: isRetryableStatus(response.status),
2192
2282
  });
2193
2283
  }
2194
2284
  }
2195
2285
  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
- }
2286
+ });
2218
2287
  }
2219
2288
  mapRunResponse(response) {
2220
2289
  // ConnectRPC returns proto enum strings like "RUN_STATUS_COMPLETED" — normalize to "completed"