unstructured-transform-client 0.18.18

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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/dist/_generated/apis/ExtractApi.d.ts +43 -0
  4. package/dist/_generated/apis/ExtractApi.js +70 -0
  5. package/dist/_generated/apis/JobsApi.d.ts +117 -0
  6. package/dist/_generated/apis/JobsApi.js +211 -0
  7. package/dist/_generated/apis/ParseApi.d.ts +79 -0
  8. package/dist/_generated/apis/ParseApi.js +111 -0
  9. package/dist/_generated/apis/UploadApi.d.ts +78 -0
  10. package/dist/_generated/apis/UploadApi.js +172 -0
  11. package/dist/_generated/apis/index.d.ts +4 -0
  12. package/dist/_generated/apis/index.js +6 -0
  13. package/dist/_generated/index.d.ts +3 -0
  14. package/dist/_generated/index.js +5 -0
  15. package/dist/_generated/models/Citation.d.ts +31 -0
  16. package/dist/_generated/models/Citation.js +44 -0
  17. package/dist/_generated/models/DocumentMetadata.d.ts +30 -0
  18. package/dist/_generated/models/DocumentMetadata.js +43 -0
  19. package/dist/_generated/models/Element.d.ts +43 -0
  20. package/dist/_generated/models/Element.js +56 -0
  21. package/dist/_generated/models/ElementMetadata.d.ts +40 -0
  22. package/dist/_generated/models/ElementMetadata.js +51 -0
  23. package/dist/_generated/models/ErrorCode.d.ts +46 -0
  24. package/dist/_generated/models/ErrorCode.js +64 -0
  25. package/dist/_generated/models/ExtractRequest.d.ts +40 -0
  26. package/dist/_generated/models/ExtractRequest.js +49 -0
  27. package/dist/_generated/models/ExtractionResult.d.ts +37 -0
  28. package/dist/_generated/models/ExtractionResult.js +47 -0
  29. package/dist/_generated/models/FieldMetadata.d.ts +31 -0
  30. package/dist/_generated/models/FieldMetadata.js +42 -0
  31. package/dist/_generated/models/JobAccepted.d.ts +67 -0
  32. package/dist/_generated/models/JobAccepted.js +73 -0
  33. package/dist/_generated/models/JobOperation.d.ts +25 -0
  34. package/dist/_generated/models/JobOperation.js +43 -0
  35. package/dist/_generated/models/JobPage.d.ts +35 -0
  36. package/dist/_generated/models/JobPage.js +48 -0
  37. package/dist/_generated/models/JobResult.d.ts +61 -0
  38. package/dist/_generated/models/JobResult.js +64 -0
  39. package/dist/_generated/models/JobResultState.d.ts +26 -0
  40. package/dist/_generated/models/JobResultState.js +44 -0
  41. package/dist/_generated/models/JobStatus.d.ts +29 -0
  42. package/dist/_generated/models/JobStatus.js +47 -0
  43. package/dist/_generated/models/JobSummary.d.ts +74 -0
  44. package/dist/_generated/models/JobSummary.js +86 -0
  45. package/dist/_generated/models/Locator.d.ts +45 -0
  46. package/dist/_generated/models/Locator.js +57 -0
  47. package/dist/_generated/models/ModelError.d.ts +35 -0
  48. package/dist/_generated/models/ModelError.js +48 -0
  49. package/dist/_generated/models/OutputFormat.d.ts +25 -0
  50. package/dist/_generated/models/OutputFormat.js +43 -0
  51. package/dist/_generated/models/ParseResult.d.ts +87 -0
  52. package/dist/_generated/models/ParseResult.js +98 -0
  53. package/dist/_generated/models/SourceFile.d.ts +42 -0
  54. package/dist/_generated/models/SourceFile.js +56 -0
  55. package/dist/_generated/models/TransformStatus.d.ts +26 -0
  56. package/dist/_generated/models/TransformStatus.js +44 -0
  57. package/dist/_generated/models/TransformWarning.d.ts +34 -0
  58. package/dist/_generated/models/TransformWarning.js +47 -0
  59. package/dist/_generated/models/UploadResult.d.ts +38 -0
  60. package/dist/_generated/models/UploadResult.js +52 -0
  61. package/dist/_generated/models/index.d.ts +23 -0
  62. package/dist/_generated/models/index.js +25 -0
  63. package/dist/_generated/runtime.d.ts +196 -0
  64. package/dist/_generated/runtime.js +398 -0
  65. package/dist/client.d.ts +145 -0
  66. package/dist/client.js +259 -0
  67. package/dist/files.d.ts +32 -0
  68. package/dist/files.js +46 -0
  69. package/dist/hostHeaders.d.ts +36 -0
  70. package/dist/hostHeaders.js +133 -0
  71. package/dist/index.d.ts +26 -0
  72. package/dist/index.js +35 -0
  73. package/dist/multipart.d.ts +50 -0
  74. package/dist/multipart.js +65 -0
  75. package/dist/outcome.d.ts +45 -0
  76. package/dist/outcome.js +40 -0
  77. package/dist/retry.d.ts +16 -0
  78. package/dist/retry.js +181 -0
  79. package/dist/sse.d.ts +50 -0
  80. package/dist/sse.js +140 -0
  81. package/dist/version.d.ts +10 -0
  82. package/dist/version.js +10 -0
  83. package/package.json +41 -0
package/dist/index.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * TypeScript client for the Unstructured Transform v2 API.
3
+ *
4
+ * import { TransformClient } from "@utic/transform-client";
5
+ *
6
+ * const client = new TransformClient({ apiKey: "..." });
7
+ * const result = await client.parse.run({ input: file });
8
+ *
9
+ * Everything exported here is public. The `_generated` directory is not — it
10
+ * is produced from openapi.yaml at build time and its layout is the
11
+ * generator's business, so importing from it directly will break without
12
+ * notice. The models re-exported below are the exception: they are the
13
+ * response types this SDK returns, so a caller needs to be able to name them.
14
+ */
15
+ export { DEFAULT_SERVER_URL, TransformClient } from "./client.js";
16
+ export { isTerminal } from "./sse.js";
17
+ export { asFile } from "./files.js";
18
+ export { isAccepted } from "./outcome.js";
19
+ export { VERSION } from "./version.js";
20
+ // The generator's error classes. `ResponseError` carries a non-2xx response,
21
+ // `FetchError` a transport failure, `RequiredError` a missing argument caught
22
+ // before the request goes out.
23
+ export { FetchError, RequiredError, ResponseError } from "./_generated/runtime.js";
24
+ // Enums are VALUES, not only types. typescript-fetch emits each as a const
25
+ // object of members plus a union type, so `export type` alone ships the type
26
+ // and silently drops the object — leaving callers unable to write
27
+ // `error.code === ErrorCode.UnsupportedFileType` against the very codes the
28
+ // API returns. Exported as values so the members exist at runtime.
29
+ export { ErrorCode, JobStatus, OutputFormat, } from "./_generated/models/index.js";
30
+ // Generated per-operation as `ParseRunProfileEnum` (named after `parseRun`,
31
+ // the operation ID), re-exported under a name that describes the field
32
+ // instead of the endpoint that happens to declare it first. Same domain on
33
+ // extract.fromDocument's `profile`, which has no generated operation of its
34
+ // own to name an enum after.
35
+ export { ParseRunProfileEnum as Profile } from "./_generated/apis/ParseApi.js";
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The multipart request openapi-generator refuses to emit.
3
+ *
4
+ * POST /api/v2/extract declares two request bodies in openapi.yaml:
5
+ *
6
+ * application/json -> ExtractRequest (extract against a parse)
7
+ * multipart/form-data -> ExtractDocumentRequest (extract from a document)
8
+ *
9
+ * The generator emits a method for the first and nothing for the second.
10
+ * `ExtractDocumentRequest` is absent from the generated models in both
11
+ * languages. There is no warning; the operation simply cannot be called that
12
+ * way from a generated client.
13
+ *
14
+ * Sent with plain `fetch` and a `FormData` body rather than through the
15
+ * generated `BaseAPI`: the generated request pipeline is built around a
16
+ * declared operation, and there is no declared operation to hand it. The
17
+ * credential and identification headers come from the client, so this call is
18
+ * authenticated and identified exactly like every other.
19
+ */
20
+ import type { Outcome } from "./outcome.js";
21
+ import type { TransformClient } from "./client.js";
22
+ export interface MultipartRequest<TResult> {
23
+ path: string;
24
+ fileField: string;
25
+ /** Omitted when the caller referenced an already-uploaded file instead. */
26
+ file?: Blob;
27
+ /**
28
+ * The filename the part carries. Required whenever `file` is given.
29
+ *
30
+ * `FormData.append(name, blob)` labels the part `blob` when no filename is
31
+ * supplied, and the service validates an upload's extension against an
32
+ * allowlist — so a perfectly good PDF comes back as "Unsupported file type".
33
+ * The Python SDK refuses a nameless document for the same reason.
34
+ */
35
+ filename?: string;
36
+ fields: Record<string, string | undefined>;
37
+ waitSeconds?: number;
38
+ /**
39
+ * The generated mapper for the 200 body.
40
+ *
41
+ * Required, not optional. A raw `as TResult` cast compiles and is wrong: the
42
+ * wire is snake_case and the SDK's types are camelCase, so `format_version`
43
+ * and `extracted_data` simply would not appear under the properties the
44
+ * types promise. Every generated call path runs its body through the
45
+ * generated mapper, and the Python facade does the equivalent via
46
+ * `response_deserialize` — this was the one path that did not.
47
+ */
48
+ fromJSON: (json: unknown) => TResult;
49
+ }
50
+ export declare function postMultipart<TResult>(client: TransformClient, request: MultipartRequest<TResult>): Promise<Outcome<TResult>>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The multipart request openapi-generator refuses to emit.
3
+ *
4
+ * POST /api/v2/extract declares two request bodies in openapi.yaml:
5
+ *
6
+ * application/json -> ExtractRequest (extract against a parse)
7
+ * multipart/form-data -> ExtractDocumentRequest (extract from a document)
8
+ *
9
+ * The generator emits a method for the first and nothing for the second.
10
+ * `ExtractDocumentRequest` is absent from the generated models in both
11
+ * languages. There is no warning; the operation simply cannot be called that
12
+ * way from a generated client.
13
+ *
14
+ * Sent with plain `fetch` and a `FormData` body rather than through the
15
+ * generated `BaseAPI`: the generated request pipeline is built around a
16
+ * declared operation, and there is no declared operation to hand it. The
17
+ * credential and identification headers come from the client, so this call is
18
+ * authenticated and identified exactly like every other.
19
+ */
20
+ import { JobAcceptedFromJSON } from "./_generated/models/index.js";
21
+ import { ResponseError } from "./_generated/runtime.js";
22
+ import { fetchWithRetries } from "./retry.js";
23
+ export async function postMultipart(client, request) {
24
+ const form = new FormData();
25
+ for (const [name, value] of Object.entries(request.fields)) {
26
+ // undefined fields are omitted rather than sent empty: the service
27
+ // distinguishes an absent optional field from a present empty one, and
28
+ // sending `prompt=` would assert the caller supplied an empty prompt.
29
+ if (value !== undefined) {
30
+ form.append(name, value);
31
+ }
32
+ }
33
+ if (request.file !== undefined) {
34
+ if (!request.filename) {
35
+ throw new TypeError("a file part needs a filename; see files.ts");
36
+ }
37
+ form.append(request.fileField, request.file, request.filename);
38
+ }
39
+ const headers = { ...client.requestHeaders };
40
+ if (request.waitSeconds !== undefined) {
41
+ headers["Prefer"] = `wait=${request.waitSeconds}`;
42
+ }
43
+ // Content-Type is deliberately NOT set. fetch derives it from the FormData
44
+ // body along with the multipart boundary; setting it by hand omits the
45
+ // boundary and the server cannot parse the body.
46
+ delete headers["Content-Type"];
47
+ const response = await fetchWithRetries(client.retries, {
48
+ url: `${client.serverUrl}${request.path}`,
49
+ fetchApi: client.fetchApi,
50
+ init: {
51
+ method: "POST",
52
+ headers,
53
+ body: form,
54
+ },
55
+ });
56
+ if (!response.ok) {
57
+ // The generated error type, so a caller catches one class of error from
58
+ // this SDK rather than two depending on which path produced it.
59
+ throw new ResponseError(response, "Response returned an error code");
60
+ }
61
+ const body = await response.json();
62
+ return response.status === 202
63
+ ? { status: 202, body: JobAcceptedFromJSON(body) }
64
+ : { status: 200, body: request.fromJSON(body) };
65
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Telling a finished result from an accepted job.
3
+ *
4
+ * THE BUG THIS EXISTS FOR: every one of these operations can answer 200 with a
5
+ * result or 202 with a job handle, and the generated code deserializes any 2xx
6
+ * through the 200 model:
7
+ *
8
+ * return new runtime.JSONApiResponse(response, (j) => ParseResultFromJSON(j));
9
+ *
10
+ * So a 202 came back as a `ParseResult` whose every field was `undefined`,
11
+ * typed as though it were real. That is not an edge case — `waitSeconds: 0` is
12
+ * the documented way to get a job handle, so the flow the API docs recommend
13
+ * returned garbage, and TypeScript said it was fine.
14
+ *
15
+ * Python is unaffected: its generated code carries a per-status response map
16
+ * and deserializes 202 to `JobAccepted` correctly.
17
+ *
18
+ * Callers discriminate with `isAccepted(outcome)`, which narrows the union.
19
+ */
20
+ import { type JobAccepted } from "./_generated/models/index.js";
21
+ import type { ApiResponse } from "./_generated/runtime.js";
22
+ /** Either the finished thing, or the handle to follow. */
23
+ export type Outcome<TResult> = {
24
+ status: 200;
25
+ body: TResult;
26
+ } | {
27
+ status: 202;
28
+ body: JobAccepted;
29
+ };
30
+ /** Narrows an outcome to the still-running case. */
31
+ export declare function isAccepted<TResult>(outcome: Outcome<TResult>): outcome is {
32
+ status: 202;
33
+ body: JobAccepted;
34
+ };
35
+ /**
36
+ * Resolve a generated `*Raw` response into the union.
37
+ *
38
+ * The `*Raw` variants are what make this possible at all: the plain ones
39
+ * return only the deserialized value, with the status already discarded.
40
+ *
41
+ * On 202 the body is read from the raw response and run through
42
+ * `JobAcceptedFromJSON`, rather than through the 200 model the generated
43
+ * wrapper would have applied.
44
+ */
45
+ export declare function resolveOutcome<TResult>(response: ApiResponse<TResult>): Promise<Outcome<TResult>>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Telling a finished result from an accepted job.
3
+ *
4
+ * THE BUG THIS EXISTS FOR: every one of these operations can answer 200 with a
5
+ * result or 202 with a job handle, and the generated code deserializes any 2xx
6
+ * through the 200 model:
7
+ *
8
+ * return new runtime.JSONApiResponse(response, (j) => ParseResultFromJSON(j));
9
+ *
10
+ * So a 202 came back as a `ParseResult` whose every field was `undefined`,
11
+ * typed as though it were real. That is not an edge case — `waitSeconds: 0` is
12
+ * the documented way to get a job handle, so the flow the API docs recommend
13
+ * returned garbage, and TypeScript said it was fine.
14
+ *
15
+ * Python is unaffected: its generated code carries a per-status response map
16
+ * and deserializes 202 to `JobAccepted` correctly.
17
+ *
18
+ * Callers discriminate with `isAccepted(outcome)`, which narrows the union.
19
+ */
20
+ import { JobAcceptedFromJSON, } from "./_generated/models/index.js";
21
+ /** Narrows an outcome to the still-running case. */
22
+ export function isAccepted(outcome) {
23
+ return outcome.status === 202;
24
+ }
25
+ /**
26
+ * Resolve a generated `*Raw` response into the union.
27
+ *
28
+ * The `*Raw` variants are what make this possible at all: the plain ones
29
+ * return only the deserialized value, with the status already discarded.
30
+ *
31
+ * On 202 the body is read from the raw response and run through
32
+ * `JobAcceptedFromJSON`, rather than through the 200 model the generated
33
+ * wrapper would have applied.
34
+ */
35
+ export async function resolveOutcome(response) {
36
+ if (response.raw.status === 202) {
37
+ return { status: 202, body: JobAcceptedFromJSON(await response.raw.json()) };
38
+ }
39
+ return { status: 200, body: await response.value() };
40
+ }
@@ -0,0 +1,16 @@
1
+ /** Retry policy for the hand-written SDK facade. */
2
+ export interface RetryConfig {
3
+ maxAttempts?: number;
4
+ initialDelayMs?: number;
5
+ maxDelayMs?: number;
6
+ maxElapsedMs?: number;
7
+ retryAfterMaxMs?: number;
8
+ }
9
+ export interface RetryRequest {
10
+ url: string;
11
+ init: RequestInit;
12
+ fetchApi?: typeof fetch;
13
+ }
14
+ export type RetryOption = RetryConfig | false | undefined;
15
+ export declare function normalizeRetries(retries: RetryOption): Required<RetryConfig> | undefined;
16
+ export declare function fetchWithRetries(retries: Required<RetryConfig> | undefined, request: RetryRequest): Promise<Response>;
package/dist/retry.js ADDED
@@ -0,0 +1,181 @@
1
+ /** Retry policy for the hand-written SDK facade. */
2
+ const DEFAULT_RETRIES = {
3
+ maxAttempts: 3,
4
+ initialDelayMs: 250,
5
+ maxDelayMs: 2000,
6
+ maxElapsedMs: 10000,
7
+ retryAfterMaxMs: 2000,
8
+ };
9
+ // Errors that prove the request never left this process, identified by their
10
+ // STRUCTURED code — never by matching text.
11
+ //
12
+ // This used to concatenate `name`, `message` and `code` and look for
13
+ // substrings, which reads any error whose prose happens to contain one of these
14
+ // words as proof of a pre-send failure. On a document API that is not
15
+ // hypothetical: `certificate` was in this set, and "certificate" is a common
16
+ // document type, so an error mentioning `certificate-of-insurance.pdf` would
17
+ // have retried a `POST /api/v2/parse` the service had already accepted —
18
+ // a second paid job, the first left running unwatched. Caught in review of the
19
+ // PR that widened this allowance to every write.
20
+ //
21
+ // `message` is attacker- and upstream-controlled in a way `code` is not, so
22
+ // only `code`/`errno` is consulted. UND_ERR_CONNECT_TIMEOUT is undici's
23
+ // connect-phase timeout: a connection never established, not a read that timed
24
+ // out. TLS is deliberately absent — see the Python twin's docstring: the same
25
+ // error shape covers a handshake failure and a TLS failure discovered while
26
+ // reading a response on a reused connection, and nothing distinguishes them.
27
+ const BEFORE_SEND_CODES = new Set([
28
+ "ECONNREFUSED",
29
+ "ENOTFOUND",
30
+ "EAI_AGAIN",
31
+ "UND_ERR_CONNECT_TIMEOUT",
32
+ ]);
33
+ const SAFE_METHODS = new Set(["GET", "DELETE"]);
34
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
35
+ export function normalizeRetries(retries) {
36
+ if (retries === false)
37
+ return undefined;
38
+ return { ...DEFAULT_RETRIES, ...(retries ?? {}) };
39
+ }
40
+ export async function fetchWithRetries(retries, request) {
41
+ const fetchApi = request.fetchApi ?? fetch;
42
+ if (retries === undefined || retries.maxAttempts <= 1) {
43
+ return fetchApi(request.url, request.init);
44
+ }
45
+ const method = (request.init.method ?? "GET").toUpperCase();
46
+ const signal = request.init.signal ?? undefined;
47
+ const started = Date.now();
48
+ let attempt = 1;
49
+ for (;;) {
50
+ try {
51
+ const response = await fetchApi(request.url, cloneInit(request.init));
52
+ if (!shouldRetryStatus(method, response.status) || !canRetry(retries, attempt, started)) {
53
+ return response;
54
+ }
55
+ await drain(response);
56
+ await sleepFor(retries, attempt, response.headers.get("Retry-After"), started, signal);
57
+ if (!canRetry(retries, attempt, started)) {
58
+ return response;
59
+ }
60
+ }
61
+ catch (error) {
62
+ if (!shouldRetryError(method, error, signal) || !canRetry(retries, attempt, started)) {
63
+ throw error;
64
+ }
65
+ await sleepFor(retries, attempt, undefined, started, signal);
66
+ if (!canRetry(retries, attempt, started)) {
67
+ throw error;
68
+ }
69
+ }
70
+ attempt += 1;
71
+ }
72
+ }
73
+ function shouldRetryStatus(method, status) {
74
+ return SAFE_METHODS.has(method) && RETRYABLE_STATUSES.has(status);
75
+ }
76
+ function shouldRetryError(method, error, signal) {
77
+ // A caller-initiated abort (e.g. cancelling jobs.stream) is not a transport
78
+ // failure — retrying it would repeat a fetch the caller already cancelled
79
+ // and delay their cancellation behind a full backoff sleep.
80
+ if (isCallerAbort(error, signal)) {
81
+ return false;
82
+ }
83
+ if (SAFE_METHODS.has(method)) {
84
+ return true;
85
+ }
86
+ // Everything else gets exactly one allowance: a failure proving the request
87
+ // never arrived. That covers the non-idempotent submits — POST /api/v2/parse,
88
+ // /api/v2/extract, /api/v2/upload — where a retry after a read timeout or 5xx
89
+ // could create a second paid job and leave the first unwatched. It also
90
+ // covers every OTHER method this policy does not name, such as
91
+ // POST /api/v2/jobs/{id}/cancel.
92
+ //
93
+ // Those used to differ, and backwards: the three submits got the before-send
94
+ // allowance and anything unnamed got nothing at all, so an unnamed POST was
95
+ // treated as more dangerous than a POST we know creates a paid job. A request
96
+ // that never left cannot have duplicated an effect no matter what the
97
+ // operation does, so there is nothing for the stricter case to protect.
98
+ // `jobs.cancel` failing once against a preview with no retry is what surfaced
99
+ // it (2026-09-10).
100
+ //
101
+ // Deliberately NOT widened past that. A 5xx or a read timeout means the
102
+ // request may well have arrived, and whether a repeated cancel is harmless is
103
+ // platform-api's contract to state, not this client's to assume.
104
+ return isBeforeSendError(error);
105
+ }
106
+ function isCallerAbort(error, signal) {
107
+ if (signal?.aborted || (signal !== undefined && Object.is(error, signal.reason))) {
108
+ return true;
109
+ }
110
+ return error instanceof Error && error.name === "AbortError";
111
+ }
112
+ function isBeforeSendError(error) {
113
+ let current = error;
114
+ const seen = new Set();
115
+ while (current instanceof Error && !seen.has(current)) {
116
+ seen.add(current);
117
+ const code = current.code ?? current.errno;
118
+ if (typeof code === "string" && BEFORE_SEND_CODES.has(code.toUpperCase())) {
119
+ return true;
120
+ }
121
+ current = current.cause;
122
+ }
123
+ return false;
124
+ }
125
+ function canRetry(retries, attempt, started) {
126
+ return attempt < retries.maxAttempts && Date.now() - started < retries.maxElapsedMs;
127
+ }
128
+ async function sleepFor(retries, attempt, retryAfter, started, signal) {
129
+ const parsed = retryAfterDelayMs(retryAfter);
130
+ const delay = parsed === undefined
131
+ ? Math.min(retries.maxDelayMs, retries.initialDelayMs * 2 ** (attempt - 1)) *
132
+ (1 + Math.random() * 0.2)
133
+ : Math.min(parsed, retries.retryAfterMaxMs);
134
+ const remaining = retries.maxElapsedMs - (Date.now() - started);
135
+ const boundedDelay = Math.max(0, Math.min(delay, remaining));
136
+ if (!signal) {
137
+ await new Promise((resolve) => setTimeout(resolve, boundedDelay));
138
+ return;
139
+ }
140
+ // An abort mid-backoff should cut the sleep short rather than making the
141
+ // caller wait out a full delay before their cancellation takes effect.
142
+ await new Promise((resolve) => {
143
+ if (signal.aborted) {
144
+ resolve();
145
+ return;
146
+ }
147
+ const timer = setTimeout(() => {
148
+ signal.removeEventListener("abort", onAbort);
149
+ resolve();
150
+ }, boundedDelay);
151
+ const onAbort = () => {
152
+ clearTimeout(timer);
153
+ resolve();
154
+ };
155
+ signal.addEventListener("abort", onAbort, { once: true });
156
+ });
157
+ }
158
+ function retryAfterDelayMs(value) {
159
+ if (!value)
160
+ return undefined;
161
+ const seconds = Number(value);
162
+ if (Number.isFinite(seconds))
163
+ return Math.max(0, seconds * 1000);
164
+ const date = Date.parse(value);
165
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
166
+ }
167
+ async function drain(response) {
168
+ try {
169
+ await response.arrayBuffer();
170
+ }
171
+ catch {
172
+ // best effort before retrying
173
+ }
174
+ }
175
+ function cloneInit(init) {
176
+ const body = init.body;
177
+ if (body instanceof ReadableStream) {
178
+ throw new TypeError("streaming request bodies cannot be retried");
179
+ }
180
+ return { ...init };
181
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The job progress stream.
3
+ *
4
+ * `GET /api/v2/jobs/{jobId}` with `Accept: text/event-stream` is the same
5
+ * resource as the JSON representation, selected by Accept rather than by a
6
+ * second route. Both are declared on that operation's 200 in openapi.yaml.
7
+ *
8
+ * THE EVENT VOCABULARY is fixed by the service and pinned by its own
9
+ * `tests/test_sse.py`. Mirrored here deliberately rather than inferred:
10
+ *
11
+ * event: status data: {"id", "status"} on every public status CHANGE
12
+ * : keep-alive a comment per unchanged poll
13
+ * event: result data: a JobResult exactly once, then the stream ends
14
+ * event: error data: {"code", "message"} exactly once instead, on failure
15
+ *
16
+ * Keep-alive comments are consumed and never yielded — they carry nothing a
17
+ * caller can act on, and surfacing them would make every consumer filter them.
18
+ *
19
+ * Written by hand rather than with `EventSource`, for two reasons that both
20
+ * matter: EventSource cannot set request headers at all, so it could not send
21
+ * the credential, and it reconnects automatically on close — which for a
22
+ * stream that ENDS on its terminal event means silently re-running the whole
23
+ * thing forever.
24
+ */
25
+ import { type RetryConfig } from "./retry.js";
26
+ /** One event from a job stream. */
27
+ export interface JobEvent {
28
+ /** The vocabulary name: `status`, `result`, or `error`. */
29
+ event: string;
30
+ /** The decoded JSON payload, or the raw string if it was not JSON. */
31
+ data: unknown;
32
+ }
33
+ /** Whether the stream ends after this event. */
34
+ export declare function isTerminal(event: JobEvent): boolean;
35
+ export interface StreamOptions {
36
+ url: string;
37
+ headers: Record<string, string>;
38
+ signal?: AbortSignal;
39
+ fetchApi?: typeof fetch;
40
+ retries?: Required<RetryConfig>;
41
+ }
42
+ /**
43
+ * Yield events until the stream closes.
44
+ *
45
+ * A non-2xx response throws rather than yielding an `error` event: an `error`
46
+ * EVENT means the job failed, an error STATUS means the request did, and
47
+ * collapsing the two would let an expired credential look like a failed
48
+ * document.
49
+ */
50
+ export declare function streamJobEvents(options: StreamOptions): AsyncGenerator<JobEvent, void, undefined>;
package/dist/sse.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The job progress stream.
3
+ *
4
+ * `GET /api/v2/jobs/{jobId}` with `Accept: text/event-stream` is the same
5
+ * resource as the JSON representation, selected by Accept rather than by a
6
+ * second route. Both are declared on that operation's 200 in openapi.yaml.
7
+ *
8
+ * THE EVENT VOCABULARY is fixed by the service and pinned by its own
9
+ * `tests/test_sse.py`. Mirrored here deliberately rather than inferred:
10
+ *
11
+ * event: status data: {"id", "status"} on every public status CHANGE
12
+ * : keep-alive a comment per unchanged poll
13
+ * event: result data: a JobResult exactly once, then the stream ends
14
+ * event: error data: {"code", "message"} exactly once instead, on failure
15
+ *
16
+ * Keep-alive comments are consumed and never yielded — they carry nothing a
17
+ * caller can act on, and surfacing them would make every consumer filter them.
18
+ *
19
+ * Written by hand rather than with `EventSource`, for two reasons that both
20
+ * matter: EventSource cannot set request headers at all, so it could not send
21
+ * the credential, and it reconnects automatically on close — which for a
22
+ * stream that ENDS on its terminal event means silently re-running the whole
23
+ * thing forever.
24
+ */
25
+ import { fetchWithRetries } from "./retry.js";
26
+ /** Whether the stream ends after this event. */
27
+ export function isTerminal(event) {
28
+ return event.event === "result" || event.event === "error";
29
+ }
30
+ /**
31
+ * Yield events until the stream closes.
32
+ *
33
+ * A non-2xx response throws rather than yielding an `error` event: an `error`
34
+ * EVENT means the job failed, an error STATUS means the request did, and
35
+ * collapsing the two would let an expired credential look like a failed
36
+ * document.
37
+ */
38
+ export async function* streamJobEvents(options) {
39
+ const response = await fetchWithRetries(options.retries, {
40
+ url: options.url,
41
+ fetchApi: options.fetchApi,
42
+ init: {
43
+ method: "GET",
44
+ headers: { ...options.headers, Accept: "text/event-stream" },
45
+ signal: options.signal,
46
+ },
47
+ });
48
+ if (!response.ok) {
49
+ const body = await response.text();
50
+ throw new Error(`job stream request failed with ${response.status}: ${body.slice(0, 500)}`);
51
+ }
52
+ if (!response.body) {
53
+ throw new Error("job stream response carried no body");
54
+ }
55
+ const reader = response.body.getReader();
56
+ const decoder = new TextDecoder();
57
+ let buffer = "";
58
+ // Accumulated, not dispatched per line. One event may carry SEVERAL `data:`
59
+ // lines, which the SSE format joins with a newline and delivers as a single
60
+ // event at the blank line. Dispatching on each `data:` line split one
61
+ // multi-line payload into several events, each holding a fragment of JSON
62
+ // that would not parse.
63
+ let eventName;
64
+ let dataLines = [];
65
+ try {
66
+ for (;;) {
67
+ const { done, value } = await reader.read();
68
+ if (done)
69
+ break;
70
+ // `stream: true` matters: a chunk boundary can fall inside a multi-byte
71
+ // character, and decoding each chunk independently would corrupt it.
72
+ buffer += decoder.decode(value, { stream: true });
73
+ let newline;
74
+ while ((newline = buffer.indexOf("\n")) !== -1) {
75
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
76
+ buffer = buffer.slice(newline + 1);
77
+ if (line === "") {
78
+ // The blank line is what dispatches. Nothing to dispatch when no
79
+ // data arrived, which is the case for a keep-alive.
80
+ if (dataLines.length > 0) {
81
+ const event = {
82
+ event: eventName ?? "message",
83
+ data: decode(dataLines.join("\n")),
84
+ };
85
+ yield event;
86
+ if (isTerminal(event))
87
+ return;
88
+ }
89
+ eventName = undefined;
90
+ dataLines = [];
91
+ continue;
92
+ }
93
+ if (line.startsWith(":")) {
94
+ continue; // keep-alive comment
95
+ }
96
+ if (line.startsWith("event:")) {
97
+ eventName = line.slice("event:".length).trim();
98
+ continue;
99
+ }
100
+ if (line.startsWith("data:")) {
101
+ // One leading space after the colon is framing, not payload;
102
+ // anything past it is data and is preserved.
103
+ const value = line.slice("data:".length);
104
+ dataLines.push(value.startsWith(" ") ? value.slice(1) : value);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ finally {
110
+ // Cancel BEFORE releasing the lock. A terminal event returns early, and a
111
+ // caller can break out of the loop at any point, so the body is usually
112
+ // half read when we get here. releaseLock() alone only detaches the
113
+ // reader — the response stays active and holds its connection.
114
+ //
115
+ // The rejection is swallowed because arriving here with an already-closed
116
+ // or errored stream is normal, and not something a caller can act on.
117
+ try {
118
+ await reader.cancel();
119
+ }
120
+ catch {
121
+ // already closed
122
+ }
123
+ reader.releaseLock();
124
+ }
125
+ }
126
+ /**
127
+ * Decode a data payload, tolerating a non-JSON one.
128
+ *
129
+ * Every event this service sends carries JSON, but a proxy injecting a
130
+ * plain-text notice should surface as that text rather than crash the consumer
131
+ * in the middle of a job.
132
+ */
133
+ function decode(payload) {
134
+ try {
135
+ return JSON.parse(payload);
136
+ }
137
+ catch {
138
+ return payload;
139
+ }
140
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The published package version.
3
+ *
4
+ * Kept in step with `services/transform-api`'s `[project].version` and with
5
+ * the Python SDK — all three describe one contract revision. Agreement is
6
+ * asserted by `tests/version.test.ts` rather than read from package.json at
7
+ * runtime, because importing JSON differs across module systems and bundlers
8
+ * and would make this module's behaviour depend on how it was built.
9
+ */
10
+ export declare const VERSION = "0.18.18";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The published package version.
3
+ *
4
+ * Kept in step with `services/transform-api`'s `[project].version` and with
5
+ * the Python SDK — all three describe one contract revision. Agreement is
6
+ * asserted by `tests/version.test.ts` rather than read from package.json at
7
+ * runtime, because importing JSON differs across module systems and bundlers
8
+ * and would make this module's behaviour depend on how it was built.
9
+ */
10
+ export const VERSION = "0.18.18";