@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/README.md +169 -3
- package/dist/client.d.ts +135 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +288 -160
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/offline.d.ts +166 -0
- package/dist/offline.d.ts.map +1 -0
- package/dist/offline.js +306 -0
- package/dist/offline.js.map +1 -0
- package/dist/queue/db.d.ts +123 -0
- package/dist/queue/db.d.ts.map +1 -0
- package/dist/queue/db.js +304 -0
- package/dist/queue/db.js.map +1 -0
- package/dist/queue/ids.d.ts +22 -0
- package/dist/queue/ids.d.ts.map +1 -0
- package/dist/queue/ids.js +45 -0
- package/dist/queue/ids.js.map +1 -0
- package/dist/queue/lock.d.ts +42 -0
- package/dist/queue/lock.d.ts.map +1 -0
- package/dist/queue/lock.js +65 -0
- package/dist/queue/lock.js.map +1 -0
- package/dist/queue/queue.d.ts +281 -0
- package/dist/queue/queue.d.ts.map +1 -0
- package/dist/queue/queue.js +894 -0
- package/dist/queue/queue.js.map +1 -0
- package/dist/queue/types.d.ts +145 -0
- package/dist/queue/types.d.ts.map +1 -0
- package/dist/queue/types.js +6 -0
- package/dist/queue/types.js.map +1 -0
- package/package.json +4 -2
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
1831
|
-
|
|
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
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|