@rustrak/client 0.18.1 → 0.19.1

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 (119) hide show
  1. package/README.md +325 -123
  2. package/dist/_virtual/_rolldown/runtime.cjs +23 -0
  3. package/dist/client.cjs +86 -0
  4. package/dist/client.cjs.map +1 -0
  5. package/dist/client.d.cts +18 -0
  6. package/dist/client.d.cts.map +1 -0
  7. package/dist/client.d.mts +18 -0
  8. package/dist/client.d.mts.map +1 -0
  9. package/dist/client.mjs +86 -0
  10. package/dist/client.mjs.map +1 -0
  11. package/dist/config.d.cts +10 -0
  12. package/dist/config.d.cts.map +1 -0
  13. package/dist/config.d.mts +10 -0
  14. package/dist/config.d.mts.map +1 -0
  15. package/dist/errors.cjs +52 -0
  16. package/dist/errors.cjs.map +1 -0
  17. package/dist/errors.d.cts +77 -0
  18. package/dist/errors.d.cts.map +1 -0
  19. package/dist/errors.d.mts +77 -0
  20. package/dist/errors.d.mts.map +1 -0
  21. package/dist/errors.mjs +48 -0
  22. package/dist/errors.mjs.map +1 -0
  23. package/dist/generated/client/client.gen.cjs +174 -0
  24. package/dist/generated/client/client.gen.cjs.map +1 -0
  25. package/dist/generated/client/client.gen.mjs +174 -0
  26. package/dist/generated/client/client.gen.mjs.map +1 -0
  27. package/dist/generated/client/types.gen.d.cts +71 -0
  28. package/dist/generated/client/types.gen.d.cts.map +1 -0
  29. package/dist/generated/client/types.gen.d.mts +71 -0
  30. package/dist/generated/client/types.gen.d.mts.map +1 -0
  31. package/dist/generated/client/utils.gen.cjs +184 -0
  32. package/dist/generated/client/utils.gen.cjs.map +1 -0
  33. package/dist/generated/client/utils.gen.d.cts +21 -0
  34. package/dist/generated/client/utils.gen.d.cts.map +1 -0
  35. package/dist/generated/client/utils.gen.d.mts +21 -0
  36. package/dist/generated/client/utils.gen.d.mts.map +1 -0
  37. package/dist/generated/client/utils.gen.mjs +177 -0
  38. package/dist/generated/client/utils.gen.mjs.map +1 -0
  39. package/dist/generated/client.gen.cjs +7 -0
  40. package/dist/generated/client.gen.cjs.map +1 -0
  41. package/dist/generated/client.gen.mjs +8 -0
  42. package/dist/generated/client.gen.mjs.map +1 -0
  43. package/dist/generated/core/auth.gen.cjs +12 -0
  44. package/dist/generated/core/auth.gen.cjs.map +1 -0
  45. package/dist/generated/core/auth.gen.d.cts +11 -0
  46. package/dist/generated/core/auth.gen.d.cts.map +1 -0
  47. package/dist/generated/core/auth.gen.d.mts +11 -0
  48. package/dist/generated/core/auth.gen.d.mts.map +1 -0
  49. package/dist/generated/core/auth.gen.mjs +12 -0
  50. package/dist/generated/core/auth.gen.mjs.map +1 -0
  51. package/dist/generated/core/bodySerializer.gen.cjs +21 -0
  52. package/dist/generated/core/bodySerializer.gen.cjs.map +1 -0
  53. package/dist/generated/core/bodySerializer.gen.d.cts +15 -0
  54. package/dist/generated/core/bodySerializer.gen.d.cts.map +1 -0
  55. package/dist/generated/core/bodySerializer.gen.d.mts +15 -0
  56. package/dist/generated/core/bodySerializer.gen.d.mts.map +1 -0
  57. package/dist/generated/core/bodySerializer.gen.mjs +20 -0
  58. package/dist/generated/core/bodySerializer.gen.mjs.map +1 -0
  59. package/dist/generated/core/pathSerializer.gen.cjs +87 -0
  60. package/dist/generated/core/pathSerializer.gen.cjs.map +1 -0
  61. package/dist/generated/core/pathSerializer.gen.d.cts +9 -0
  62. package/dist/generated/core/pathSerializer.gen.d.cts.map +1 -0
  63. package/dist/generated/core/pathSerializer.gen.d.mts +9 -0
  64. package/dist/generated/core/pathSerializer.gen.d.mts.map +1 -0
  65. package/dist/generated/core/pathSerializer.gen.mjs +82 -0
  66. package/dist/generated/core/pathSerializer.gen.mjs.map +1 -0
  67. package/dist/generated/core/serverSentEvents.gen.cjs +96 -0
  68. package/dist/generated/core/serverSentEvents.gen.cjs.map +1 -0
  69. package/dist/generated/core/serverSentEvents.gen.d.cts +25 -0
  70. package/dist/generated/core/serverSentEvents.gen.d.cts.map +1 -0
  71. package/dist/generated/core/serverSentEvents.gen.d.mts +25 -0
  72. package/dist/generated/core/serverSentEvents.gen.d.mts.map +1 -0
  73. package/dist/generated/core/serverSentEvents.gen.mjs +96 -0
  74. package/dist/generated/core/serverSentEvents.gen.mjs.map +1 -0
  75. package/dist/generated/core/types.gen.d.cts +27 -0
  76. package/dist/generated/core/types.gen.d.cts.map +1 -0
  77. package/dist/generated/core/types.gen.d.mts +27 -0
  78. package/dist/generated/core/types.gen.d.mts.map +1 -0
  79. package/dist/generated/core/utils.gen.cjs +81 -0
  80. package/dist/generated/core/utils.gen.cjs.map +1 -0
  81. package/dist/generated/core/utils.gen.mjs +78 -0
  82. package/dist/generated/core/utils.gen.mjs.map +1 -0
  83. package/dist/generated/sdk.gen.cjs +3334 -0
  84. package/dist/generated/sdk.gen.cjs.map +1 -0
  85. package/dist/generated/sdk.gen.d.cts +288 -0
  86. package/dist/generated/sdk.gen.d.cts.map +1 -0
  87. package/dist/generated/sdk.gen.d.mts +288 -0
  88. package/dist/generated/sdk.gen.d.mts.map +1 -0
  89. package/dist/generated/sdk.gen.mjs +3305 -0
  90. package/dist/generated/sdk.gen.mjs.map +1 -0
  91. package/dist/generated/types.gen.d.cts +3915 -0
  92. package/dist/generated/types.gen.d.cts.map +1 -0
  93. package/dist/generated/types.gen.d.mts +3915 -0
  94. package/dist/generated/types.gen.d.mts.map +1 -0
  95. package/dist/generated/zod.gen.cjs +3273 -0
  96. package/dist/generated/zod.gen.cjs.map +1 -0
  97. package/dist/generated/zod.gen.mjs +2789 -0
  98. package/dist/generated/zod.gen.mjs.map +1 -0
  99. package/dist/index.cjs +15 -3248
  100. package/dist/index.d.cts +6 -2037
  101. package/dist/index.d.mts +6 -0
  102. package/dist/index.mjs +4 -0
  103. package/dist/result.cjs +59 -0
  104. package/dist/result.cjs.map +1 -0
  105. package/dist/result.d.cts +15 -0
  106. package/dist/result.d.cts.map +1 -0
  107. package/dist/result.d.mts +15 -0
  108. package/dist/result.d.mts.map +1 -0
  109. package/dist/result.mjs +55 -0
  110. package/dist/result.mjs.map +1 -0
  111. package/dist/utils/http.cjs +339 -0
  112. package/dist/utils/http.cjs.map +1 -0
  113. package/dist/utils/http.mjs +336 -0
  114. package/dist/utils/http.mjs.map +1 -0
  115. package/package.json +17 -12
  116. package/dist/index.cjs.map +0 -1
  117. package/dist/index.d.ts +0 -2037
  118. package/dist/index.js +0 -3232
  119. package/dist/index.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.cjs","names":["FIELD_ERROR_CODES","SERVER_ERROR_MESSAGE","TIMEOUT_ERROR_MESSAGE","NETWORK_ERROR_MESSAGE"],"sources":["../../src/utils/http.ts"],"sourcesContent":["import type { ClientConfig } from '../config.js';\nimport {\n FIELD_ERROR_CODES,\n type FieldError,\n type FieldErrorCode,\n NETWORK_ERROR_MESSAGE,\n type RustrakError,\n SERVER_ERROR_MESSAGE,\n TIMEOUT_ERROR_MESSAGE,\n} from '../errors.js';\n\n/**\n * Internal carrier for a transport failure travelling out of `fetch`.\n *\n * `fetch` signals \"no response\" by rejecting with a `TypeError`, which is also\n * what a genuine bug throws. {@link createFetch} wraps the rejections it\n * recognises in this class so the `Result` boundary in `client.ts` can tell\n * the two apart: this becomes an `Err`, anything else keeps propagating.\n *\n * Never exported from `src/index.ts`.\n */\nexport class RustrakTransportFailure extends Error {\n public readonly rustrakError: RustrakError;\n\n constructor(error: RustrakError) {\n super(error.message);\n this.name = 'RustrakTransportFailure';\n this.rustrakError = error;\n }\n}\n\n/** Narrow a caught value to the internal carrier. */\nexport function isTransportFailure(\n value: unknown,\n): value is RustrakTransportFailure {\n return value instanceof RustrakTransportFailure;\n}\n\n/**\n * Read the human-readable message out of an error body.\n *\n * Two shapes are live: every `AppError` sends `{error: {type, message}}` while\n * the ingest rate limiter sends a flat `{error: \"...\"}`. Reading only the flat\n * one turned every structured error into \"[object Object]\". See #204.\n */\nfunction readErrorMessage(body: unknown, status: number): string {\n const fallback = `HTTP ${status} error`;\n\n // `typeof body === 'object'` on purpose: a non-JSON body arrives as a plain\n // string, and property access on a string primitive yields undefined, so a\n // bare `if (body)` fell through to the generic message only by accident.\n // Make the fallback deliberate.\n if (body && typeof body === 'object') {\n const { error, message } = body as {\n error?: string | { type?: string; message?: string };\n message?: string;\n };\n const nested = typeof error === 'object' ? error?.message : error;\n return nested || message || fallback;\n }\n\n return fallback;\n}\n\nconst KNOWN_FIELD_ERROR_CODES = new Set<string>(FIELD_ERROR_CODES);\n\nfunction isFieldErrorCode(value: unknown): value is FieldErrorCode {\n return typeof value === 'string' && KNOWN_FIELD_ERROR_CODES.has(value);\n}\n\n/**\n * Codes already reported by {@link warnUnknownFieldErrorCode}.\n *\n * Module-scoped so a list page rendering fifty rows against a newer server\n * logs once per unknown code rather than once per row. Version skew does not\n * change within a process, so once is the whole signal.\n */\nconst warnedFieldErrorCodes = new Set<string>();\n\n/**\n * Make an unrecognised `code` visible without admitting it into the union.\n *\n * Dropping the entry is correct (see {@link readFieldErrors}), but a silent\n * drop is indistinguishable from \"the server named no field\": a client one\n * release behind a server that added a code loses every inline form error\n * site-wide with nothing anywhere to explain it.\n *\n * `console.warn` is the least intrusive mechanism that a support engineer can\n * actually act on. It needs no change to the public type, so no consumer has\n * to opt in to see it; it lands in the browser devtools console in a client\n * component and on stdout of the Node process (and therefore in whatever log\n * aggregation already exists) in a Server Component. The alternatives were\n * worse: throwing turns a cosmetic degradation into an outage, admitting the\n * code breaks the closed union this drop exists to protect, and a new\n * `fieldsDropped` flag on the union is invisible until somebody thinks to read\n * it, which is the same silence in a new place.\n */\nfunction warnUnknownFieldErrorCode(code: string, field: string): void {\n if (warnedFieldErrorCodes.has(code)) return;\n warnedFieldErrorCodes.add(code);\n\n console.warn(\n `[@rustrak/client] Dropped a field error with an unrecognised code ` +\n `\"${code}\" (field \"${field}\"). This client build does not know that ` +\n `code, so the affected inputs degrade to a form-level error. The ` +\n `server is most likely newer than @rustrak/client; upgrade the client ` +\n `to match the server version.`,\n );\n}\n\n/**\n * Read `error.fields` out of an `AppError` body.\n *\n * Returns `undefined` rather than `[]` when there is nothing usable, so the\n * key stays absent on the `RustrakError` and a consumer can test for it.\n *\n * Entries are validated one by one and a malformed or unrecognised one is\n * dropped, never coerced. `FieldErrorCode` is a closed union: admitting a code\n * this build has never heard of would hand a consumer a value its own\n * exhaustive `switch` cannot see. A newer server adding a code therefore\n * degrades to a form-level error here, which is the same place an older client\n * already lands, and is announced once via\n * {@link warnUnknownFieldErrorCode} so the skew is not silent.\n *\n * `message` survives only alongside `code: 'custom'`. The server enforces the\n * same rule at construction, but the docs on both sides tell consumers to\n * select translated copy from `(field, code)` for every other code, so a\n * `message` that reaches them there is untranslatable English a careless\n * consumer would render. Dropping it here makes the rule true of the value, not\n * only of its producer.\n */\n/**\n * Read `error.incident_id` out of a 5xx `AppError` body.\n *\n * The one thing a 500 is willing to say about itself. `error_response` replaced\n * its `Display` with a fixed string so a `sqlx::Error`'s constraint and column\n * names stay off the wire (gh-233), and logged the detail under this id; a user\n * quoting it is what lets an operator find that line.\n *\n * Returns `undefined`, never `null` or `''`, for the three bodies that carry no\n * id: a proxy-generated 502/503, a server older than gh-233, and a 500 whose\n * body is not JSON at all. The caller omits the key entirely in that case, so\n * `'incidentId' in error` stays an honest test.\n */\nfunction readIncidentId(body: unknown): string | undefined {\n const raw =\n body && typeof body === 'object'\n ? (body as { error?: { incident_id?: unknown } }).error?.incident_id\n : undefined;\n\n return typeof raw === 'string' && raw.length > 0 ? raw : undefined;\n}\n\nfunction readFieldErrors(body: unknown): FieldError[] | undefined {\n const raw =\n body && typeof body === 'object'\n ? (body as { error?: { fields?: unknown } }).error?.fields\n : undefined;\n\n if (!Array.isArray(raw)) {\n return undefined;\n }\n\n const fields = raw.flatMap((entry) => readFieldError(entry) ?? []);\n return fields.length > 0 ? fields : undefined;\n}\n\n/** One entry of `error.fields`, or `undefined` when it is unusable. */\nfunction readFieldError(entry: unknown): FieldError | undefined {\n if (typeof entry !== 'object' || entry === null) return undefined;\n\n const { field, code, message } = entry as Record<string, unknown>;\n if (typeof field !== 'string') return undefined;\n if (!isFieldErrorCode(code)) {\n if (typeof code === 'string') warnUnknownFieldErrorCode(code, field);\n return undefined;\n }\n\n return code === 'custom' && typeof message === 'string'\n ? { field, code, message }\n : { field, code };\n}\n\n/**\n * `Retry-After` in seconds, when the server sent a parseable one.\n *\n * RFC 9110 §10.2.3 allows two forms and a proxy in front of the Rust process\n * may send either. `Number.parseInt` read only the first, and read it too\n * loosely: it dropped the HTTP-date form entirely as `NaN`, and it returned\n * `-5` from `-5` and `3` from `3abc`, both of which a consumer would pass\n * straight to `setTimeout`.\n *\n * A date already in the past yields `0`, meaning \"retry now\", which is the\n * literal reading of the header.\n */\nfunction readRetryAfter(response: Response): number | undefined {\n const header = response.headers.get('Retry-After')?.trim();\n if (!header) {\n return undefined;\n }\n\n // delta-seconds: digits only. No sign, no trailing junk.\n if (/^\\d+$/.test(header)) {\n return Number.parseInt(header, 10);\n }\n\n // A signed number is neither a valid delta-seconds nor an HTTP-date, and it\n // must not reach `Date.parse`: V8 reads `-5` as a year and would turn a\n // nonsense header into \"retry now\".\n if (/^[+-]\\d+$/.test(header)) {\n return undefined;\n }\n\n const date = Date.parse(header);\n if (Number.isNaN(date)) {\n return undefined;\n }\n\n return Math.max(0, Math.ceil((date - Date.now()) / 1000));\n}\n\n/**\n * Attach `fields` to an error, or leave the key off entirely.\n *\n * The key is omitted rather than set to `undefined` so `'fields' in error`\n * stays an honest test and the object round-trips through `structuredClone`\n * with the same shape it was built with.\n */\nfunction annotate<E extends RustrakError & { fields?: readonly FieldError[] }>(\n base: E,\n fields: readonly FieldError[] | undefined,\n): E {\n return fields === undefined ? base : { ...base, fields };\n}\n\n/**\n * Map an HTTP status onto a `RustrakError` kind.\n *\n * Total by construction: every 5xx redacts, every other unanticipated status\n * lands on `client_error`, so no status is unrepresentable.\n */\nexport function transformHttpError(\n response: Response,\n body: unknown,\n): RustrakError {\n const status = response.status;\n\n // 5xx never carries a server-supplied message. The server redacts its own\n // `Display` now (gh-233), but this stays: a 502 from a reverse proxy and a\n // server older than that fix both still send prose, and every consumer of\n // this client renders `error.message` somewhere. Discard it here, once,\n // rather than trusting 128 call sites to remember.\n //\n // `incidentId` is the exception, and the reason the redaction is no longer\n // lossy: it names the log line carrying what the body dropped, and is\n // omitted entirely when the body has none.\n if (status >= 500) {\n const base = {\n kind: 'server_error',\n status,\n message: SERVER_ERROR_MESSAGE,\n } as const;\n const incidentId = readIncidentId(body);\n\n return incidentId === undefined ? base : { ...base, incidentId };\n }\n\n const message = readErrorMessage(body, status);\n\n // Read once, for every non-5xx status. `AppError::with_field` works on all\n // eight Rust variants, so restricting this to 400 and 409 meant an\n // annotation added to, say, the `Forbidden` in `routes/team.rs` would be\n // serialised, documented in `openapi.json`, and invisible to every consumer\n // with no test failing anywhere. It costs nothing when the key is absent:\n // `readFieldErrors` returns `undefined` without allocating.\n const fields = readFieldErrors(body);\n\n switch (status) {\n case 400:\n // The key is omitted rather than set to `undefined` so that\n // `'fields' in error` stays an honest test, matching `retryAfter` below.\n return annotate({ kind: 'validation', status, message }, fields);\n case 401:\n return annotate({ kind: 'unauthenticated', status, message }, fields);\n case 403:\n return annotate({ kind: 'forbidden', status, message }, fields);\n case 404:\n return annotate({ kind: 'not_found', status, message }, fields);\n case 409:\n return annotate({ kind: 'conflict', status, message }, fields);\n case 410:\n return annotate({ kind: 'gone', status, message }, fields);\n case 413:\n return annotate({ kind: 'payload_too_large', status, message }, fields);\n case 429: {\n const retryAfter = readRetryAfter(response);\n return annotate(\n retryAfter === undefined\n ? { kind: 'rate_limited', status, message }\n : { kind: 'rate_limited', status, message, retryAfter },\n fields,\n );\n }\n default:\n return annotate({ kind: 'client_error', status, message }, fields);\n }\n}\n\n/** Statuses a `Response` may not be constructed with a body for. */\nconst NULL_BODY = new Set([101, 103, 204, 205, 304]);\n\n/** Statuses worth repeating: the request may well succeed a moment later. */\nconst RETRY_STATUSES = new Set([408, 500, 502, 503, 504]);\n\n/**\n * Only methods that are safe to send twice. A `POST` that timed out at the\n * proxy may still have created the row, and a second one creates another.\n */\nconst RETRY_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE']);\n\n/**\n * The `fetch` every request goes through: timeout, retries, and the line\n * between a transport failure and a bug.\n *\n * The body is read here, inside the same `try` as the request, so a\n * connection that dies halfway through a body is a transport failure like\n * one that never connected, not an error the generated client half-parses.\n *\n * Retries wait 300ms, then 600ms, and so on. A caller's own `AbortSignal`\n * aborting is not a transport failure: it rethrows as the `AbortError` the\n * caller asked for.\n */\nexport function createFetch(config: ClientConfig): typeof fetch {\n const timeout = config.timeout ?? 30_000;\n const maxRetries = config.maxRetries ?? 2;\n\n return async (input, init) => {\n const request = new Request(input, init);\n const retries = RETRY_METHODS.has(request.method) ? maxRetries : 0;\n\n for (let n = 0; ; n++) {\n const outcome = await attempt(request, timeout);\n // A timeout is not repeated: the server may still be working on it.\n const retryable =\n outcome instanceof Response\n ? RETRY_STATUSES.has(outcome.status)\n : outcome.reason === 'unreachable';\n if (n < retries && retryable) {\n await sleep(n);\n continue;\n }\n if (outcome instanceof Response) return outcome;\n throw new RustrakTransportFailure(outcome);\n }\n };\n}\n\n/** One try: the response with its body read, or the failure it ended in. */\nasync function attempt(\n request: Request,\n timeout: number,\n): Promise<Response | Extract<RustrakError, { kind: 'network' }>> {\n try {\n const response = await fetch(request.clone(), {\n signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeout)]),\n });\n const body = await response.arrayBuffer();\n // The generated client returns `{}` unvalidated for `Content-Length: 0`.\n // Without it an empty 200 is parsed, and validated, like any other.\n const headers = new Headers(response.headers);\n headers.delete('Content-Length');\n return new Response(NULL_BODY.has(response.status) ? null : body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n } catch (error) {\n if (request.signal.aborted) throw error;\n // No `cause`, and never `error.message`: both embed the resolved host and\n // port, which must not ride along into a serialized payload or a UI.\n return error instanceof DOMException && error.name === 'TimeoutError'\n ? { kind: 'network', message: TIMEOUT_ERROR_MESSAGE, reason: 'timeout' }\n : {\n kind: 'network',\n message: NETWORK_ERROR_MESSAGE,\n reason: 'unreachable',\n };\n }\n}\n\nfunction sleep(attempt: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));\n}\n"],"mappings":"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"}
@@ -0,0 +1,336 @@
1
+ import { FIELD_ERROR_CODES, NETWORK_ERROR_MESSAGE, SERVER_ERROR_MESSAGE, TIMEOUT_ERROR_MESSAGE } from "../errors.mjs";
2
+ //#region src/utils/http.ts
3
+ /**
4
+ * Internal carrier for a transport failure travelling out of `fetch`.
5
+ *
6
+ * `fetch` signals "no response" by rejecting with a `TypeError`, which is also
7
+ * what a genuine bug throws. {@link createFetch} wraps the rejections it
8
+ * recognises in this class so the `Result` boundary in `client.ts` can tell
9
+ * the two apart: this becomes an `Err`, anything else keeps propagating.
10
+ *
11
+ * Never exported from `src/index.ts`.
12
+ */
13
+ var RustrakTransportFailure = class extends Error {
14
+ rustrakError;
15
+ constructor(error) {
16
+ super(error.message);
17
+ this.name = "RustrakTransportFailure";
18
+ this.rustrakError = error;
19
+ }
20
+ };
21
+ /** Narrow a caught value to the internal carrier. */
22
+ function isTransportFailure(value) {
23
+ return value instanceof RustrakTransportFailure;
24
+ }
25
+ /**
26
+ * Read the human-readable message out of an error body.
27
+ *
28
+ * Two shapes are live: every `AppError` sends `{error: {type, message}}` while
29
+ * the ingest rate limiter sends a flat `{error: "..."}`. Reading only the flat
30
+ * one turned every structured error into "[object Object]". See #204.
31
+ */
32
+ function readErrorMessage(body, status) {
33
+ const fallback = `HTTP ${status} error`;
34
+ if (body && typeof body === "object") {
35
+ const { error, message } = body;
36
+ return (typeof error === "object" ? error?.message : error) || message || fallback;
37
+ }
38
+ return fallback;
39
+ }
40
+ const KNOWN_FIELD_ERROR_CODES = new Set(FIELD_ERROR_CODES);
41
+ function isFieldErrorCode(value) {
42
+ return typeof value === "string" && KNOWN_FIELD_ERROR_CODES.has(value);
43
+ }
44
+ /**
45
+ * Codes already reported by {@link warnUnknownFieldErrorCode}.
46
+ *
47
+ * Module-scoped so a list page rendering fifty rows against a newer server
48
+ * logs once per unknown code rather than once per row. Version skew does not
49
+ * change within a process, so once is the whole signal.
50
+ */
51
+ const warnedFieldErrorCodes = /* @__PURE__ */ new Set();
52
+ /**
53
+ * Make an unrecognised `code` visible without admitting it into the union.
54
+ *
55
+ * Dropping the entry is correct (see {@link readFieldErrors}), but a silent
56
+ * drop is indistinguishable from "the server named no field": a client one
57
+ * release behind a server that added a code loses every inline form error
58
+ * site-wide with nothing anywhere to explain it.
59
+ *
60
+ * `console.warn` is the least intrusive mechanism that a support engineer can
61
+ * actually act on. It needs no change to the public type, so no consumer has
62
+ * to opt in to see it; it lands in the browser devtools console in a client
63
+ * component and on stdout of the Node process (and therefore in whatever log
64
+ * aggregation already exists) in a Server Component. The alternatives were
65
+ * worse: throwing turns a cosmetic degradation into an outage, admitting the
66
+ * code breaks the closed union this drop exists to protect, and a new
67
+ * `fieldsDropped` flag on the union is invisible until somebody thinks to read
68
+ * it, which is the same silence in a new place.
69
+ */
70
+ function warnUnknownFieldErrorCode(code, field) {
71
+ if (warnedFieldErrorCodes.has(code)) return;
72
+ warnedFieldErrorCodes.add(code);
73
+ console.warn(`[@rustrak/client] Dropped a field error with an unrecognised code "${code}" (field "${field}"). This client build does not know that code, so the affected inputs degrade to a form-level error. The server is most likely newer than @rustrak/client; upgrade the client to match the server version.`);
74
+ }
75
+ /**
76
+ * Read `error.fields` out of an `AppError` body.
77
+ *
78
+ * Returns `undefined` rather than `[]` when there is nothing usable, so the
79
+ * key stays absent on the `RustrakError` and a consumer can test for it.
80
+ *
81
+ * Entries are validated one by one and a malformed or unrecognised one is
82
+ * dropped, never coerced. `FieldErrorCode` is a closed union: admitting a code
83
+ * this build has never heard of would hand a consumer a value its own
84
+ * exhaustive `switch` cannot see. A newer server adding a code therefore
85
+ * degrades to a form-level error here, which is the same place an older client
86
+ * already lands, and is announced once via
87
+ * {@link warnUnknownFieldErrorCode} so the skew is not silent.
88
+ *
89
+ * `message` survives only alongside `code: 'custom'`. The server enforces the
90
+ * same rule at construction, but the docs on both sides tell consumers to
91
+ * select translated copy from `(field, code)` for every other code, so a
92
+ * `message` that reaches them there is untranslatable English a careless
93
+ * consumer would render. Dropping it here makes the rule true of the value, not
94
+ * only of its producer.
95
+ */
96
+ /**
97
+ * Read `error.incident_id` out of a 5xx `AppError` body.
98
+ *
99
+ * The one thing a 500 is willing to say about itself. `error_response` replaced
100
+ * its `Display` with a fixed string so a `sqlx::Error`'s constraint and column
101
+ * names stay off the wire (gh-233), and logged the detail under this id; a user
102
+ * quoting it is what lets an operator find that line.
103
+ *
104
+ * Returns `undefined`, never `null` or `''`, for the three bodies that carry no
105
+ * id: a proxy-generated 502/503, a server older than gh-233, and a 500 whose
106
+ * body is not JSON at all. The caller omits the key entirely in that case, so
107
+ * `'incidentId' in error` stays an honest test.
108
+ */
109
+ function readIncidentId(body) {
110
+ const raw = body && typeof body === "object" ? body.error?.incident_id : void 0;
111
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
112
+ }
113
+ function readFieldErrors(body) {
114
+ const raw = body && typeof body === "object" ? body.error?.fields : void 0;
115
+ if (!Array.isArray(raw)) return;
116
+ const fields = raw.flatMap((entry) => readFieldError(entry) ?? []);
117
+ return fields.length > 0 ? fields : void 0;
118
+ }
119
+ /** One entry of `error.fields`, or `undefined` when it is unusable. */
120
+ function readFieldError(entry) {
121
+ if (typeof entry !== "object" || entry === null) return void 0;
122
+ const { field, code, message } = entry;
123
+ if (typeof field !== "string") return void 0;
124
+ if (!isFieldErrorCode(code)) {
125
+ if (typeof code === "string") warnUnknownFieldErrorCode(code, field);
126
+ return;
127
+ }
128
+ return code === "custom" && typeof message === "string" ? {
129
+ field,
130
+ code,
131
+ message
132
+ } : {
133
+ field,
134
+ code
135
+ };
136
+ }
137
+ /**
138
+ * `Retry-After` in seconds, when the server sent a parseable one.
139
+ *
140
+ * RFC 9110 §10.2.3 allows two forms and a proxy in front of the Rust process
141
+ * may send either. `Number.parseInt` read only the first, and read it too
142
+ * loosely: it dropped the HTTP-date form entirely as `NaN`, and it returned
143
+ * `-5` from `-5` and `3` from `3abc`, both of which a consumer would pass
144
+ * straight to `setTimeout`.
145
+ *
146
+ * A date already in the past yields `0`, meaning "retry now", which is the
147
+ * literal reading of the header.
148
+ */
149
+ function readRetryAfter(response) {
150
+ const header = response.headers.get("Retry-After")?.trim();
151
+ if (!header) return;
152
+ if (/^\d+$/.test(header)) return Number.parseInt(header, 10);
153
+ if (/^[+-]\d+$/.test(header)) return;
154
+ const date = Date.parse(header);
155
+ if (Number.isNaN(date)) return;
156
+ return Math.max(0, Math.ceil((date - Date.now()) / 1e3));
157
+ }
158
+ /**
159
+ * Attach `fields` to an error, or leave the key off entirely.
160
+ *
161
+ * The key is omitted rather than set to `undefined` so `'fields' in error`
162
+ * stays an honest test and the object round-trips through `structuredClone`
163
+ * with the same shape it was built with.
164
+ */
165
+ function annotate(base, fields) {
166
+ return fields === void 0 ? base : {
167
+ ...base,
168
+ fields
169
+ };
170
+ }
171
+ /**
172
+ * Map an HTTP status onto a `RustrakError` kind.
173
+ *
174
+ * Total by construction: every 5xx redacts, every other unanticipated status
175
+ * lands on `client_error`, so no status is unrepresentable.
176
+ */
177
+ function transformHttpError(response, body) {
178
+ const status = response.status;
179
+ if (status >= 500) {
180
+ const base = {
181
+ kind: "server_error",
182
+ status,
183
+ message: SERVER_ERROR_MESSAGE
184
+ };
185
+ const incidentId = readIncidentId(body);
186
+ return incidentId === void 0 ? base : {
187
+ ...base,
188
+ incidentId
189
+ };
190
+ }
191
+ const message = readErrorMessage(body, status);
192
+ const fields = readFieldErrors(body);
193
+ switch (status) {
194
+ case 400: return annotate({
195
+ kind: "validation",
196
+ status,
197
+ message
198
+ }, fields);
199
+ case 401: return annotate({
200
+ kind: "unauthenticated",
201
+ status,
202
+ message
203
+ }, fields);
204
+ case 403: return annotate({
205
+ kind: "forbidden",
206
+ status,
207
+ message
208
+ }, fields);
209
+ case 404: return annotate({
210
+ kind: "not_found",
211
+ status,
212
+ message
213
+ }, fields);
214
+ case 409: return annotate({
215
+ kind: "conflict",
216
+ status,
217
+ message
218
+ }, fields);
219
+ case 410: return annotate({
220
+ kind: "gone",
221
+ status,
222
+ message
223
+ }, fields);
224
+ case 413: return annotate({
225
+ kind: "payload_too_large",
226
+ status,
227
+ message
228
+ }, fields);
229
+ case 429: {
230
+ const retryAfter = readRetryAfter(response);
231
+ return annotate(retryAfter === void 0 ? {
232
+ kind: "rate_limited",
233
+ status,
234
+ message
235
+ } : {
236
+ kind: "rate_limited",
237
+ status,
238
+ message,
239
+ retryAfter
240
+ }, fields);
241
+ }
242
+ default: return annotate({
243
+ kind: "client_error",
244
+ status,
245
+ message
246
+ }, fields);
247
+ }
248
+ }
249
+ /** Statuses a `Response` may not be constructed with a body for. */
250
+ const NULL_BODY = /* @__PURE__ */ new Set([
251
+ 101,
252
+ 103,
253
+ 204,
254
+ 205,
255
+ 304
256
+ ]);
257
+ /** Statuses worth repeating: the request may well succeed a moment later. */
258
+ const RETRY_STATUSES = /* @__PURE__ */ new Set([
259
+ 408,
260
+ 500,
261
+ 502,
262
+ 503,
263
+ 504
264
+ ]);
265
+ /**
266
+ * Only methods that are safe to send twice. A `POST` that timed out at the
267
+ * proxy may still have created the row, and a second one creates another.
268
+ */
269
+ const RETRY_METHODS = /* @__PURE__ */ new Set([
270
+ "GET",
271
+ "HEAD",
272
+ "PUT",
273
+ "DELETE"
274
+ ]);
275
+ /**
276
+ * The `fetch` every request goes through: timeout, retries, and the line
277
+ * between a transport failure and a bug.
278
+ *
279
+ * The body is read here, inside the same `try` as the request, so a
280
+ * connection that dies halfway through a body is a transport failure like
281
+ * one that never connected, not an error the generated client half-parses.
282
+ *
283
+ * Retries wait 300ms, then 600ms, and so on. A caller's own `AbortSignal`
284
+ * aborting is not a transport failure: it rethrows as the `AbortError` the
285
+ * caller asked for.
286
+ */
287
+ function createFetch(config) {
288
+ const timeout = config.timeout ?? 3e4;
289
+ const maxRetries = config.maxRetries ?? 2;
290
+ return async (input, init) => {
291
+ const request = new Request(input, init);
292
+ const retries = RETRY_METHODS.has(request.method) ? maxRetries : 0;
293
+ for (let n = 0;; n++) {
294
+ const outcome = await attempt(request, timeout);
295
+ const retryable = outcome instanceof Response ? RETRY_STATUSES.has(outcome.status) : outcome.reason === "unreachable";
296
+ if (n < retries && retryable) {
297
+ await sleep(n);
298
+ continue;
299
+ }
300
+ if (outcome instanceof Response) return outcome;
301
+ throw new RustrakTransportFailure(outcome);
302
+ }
303
+ };
304
+ }
305
+ /** One try: the response with its body read, or the failure it ended in. */
306
+ async function attempt(request, timeout) {
307
+ try {
308
+ const response = await fetch(request.clone(), { signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeout)]) });
309
+ const body = await response.arrayBuffer();
310
+ const headers = new Headers(response.headers);
311
+ headers.delete("Content-Length");
312
+ return new Response(NULL_BODY.has(response.status) ? null : body, {
313
+ status: response.status,
314
+ statusText: response.statusText,
315
+ headers
316
+ });
317
+ } catch (error) {
318
+ if (request.signal.aborted) throw error;
319
+ return error instanceof DOMException && error.name === "TimeoutError" ? {
320
+ kind: "network",
321
+ message: TIMEOUT_ERROR_MESSAGE,
322
+ reason: "timeout"
323
+ } : {
324
+ kind: "network",
325
+ message: NETWORK_ERROR_MESSAGE,
326
+ reason: "unreachable"
327
+ };
328
+ }
329
+ }
330
+ function sleep(attempt) {
331
+ return new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));
332
+ }
333
+ //#endregion
334
+ export { RustrakTransportFailure, createFetch, isTransportFailure, transformHttpError };
335
+
336
+ //# sourceMappingURL=http.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.mjs","names":[],"sources":["../../src/utils/http.ts"],"sourcesContent":["import type { ClientConfig } from '../config.js';\nimport {\n FIELD_ERROR_CODES,\n type FieldError,\n type FieldErrorCode,\n NETWORK_ERROR_MESSAGE,\n type RustrakError,\n SERVER_ERROR_MESSAGE,\n TIMEOUT_ERROR_MESSAGE,\n} from '../errors.js';\n\n/**\n * Internal carrier for a transport failure travelling out of `fetch`.\n *\n * `fetch` signals \"no response\" by rejecting with a `TypeError`, which is also\n * what a genuine bug throws. {@link createFetch} wraps the rejections it\n * recognises in this class so the `Result` boundary in `client.ts` can tell\n * the two apart: this becomes an `Err`, anything else keeps propagating.\n *\n * Never exported from `src/index.ts`.\n */\nexport class RustrakTransportFailure extends Error {\n public readonly rustrakError: RustrakError;\n\n constructor(error: RustrakError) {\n super(error.message);\n this.name = 'RustrakTransportFailure';\n this.rustrakError = error;\n }\n}\n\n/** Narrow a caught value to the internal carrier. */\nexport function isTransportFailure(\n value: unknown,\n): value is RustrakTransportFailure {\n return value instanceof RustrakTransportFailure;\n}\n\n/**\n * Read the human-readable message out of an error body.\n *\n * Two shapes are live: every `AppError` sends `{error: {type, message}}` while\n * the ingest rate limiter sends a flat `{error: \"...\"}`. Reading only the flat\n * one turned every structured error into \"[object Object]\". See #204.\n */\nfunction readErrorMessage(body: unknown, status: number): string {\n const fallback = `HTTP ${status} error`;\n\n // `typeof body === 'object'` on purpose: a non-JSON body arrives as a plain\n // string, and property access on a string primitive yields undefined, so a\n // bare `if (body)` fell through to the generic message only by accident.\n // Make the fallback deliberate.\n if (body && typeof body === 'object') {\n const { error, message } = body as {\n error?: string | { type?: string; message?: string };\n message?: string;\n };\n const nested = typeof error === 'object' ? error?.message : error;\n return nested || message || fallback;\n }\n\n return fallback;\n}\n\nconst KNOWN_FIELD_ERROR_CODES = new Set<string>(FIELD_ERROR_CODES);\n\nfunction isFieldErrorCode(value: unknown): value is FieldErrorCode {\n return typeof value === 'string' && KNOWN_FIELD_ERROR_CODES.has(value);\n}\n\n/**\n * Codes already reported by {@link warnUnknownFieldErrorCode}.\n *\n * Module-scoped so a list page rendering fifty rows against a newer server\n * logs once per unknown code rather than once per row. Version skew does not\n * change within a process, so once is the whole signal.\n */\nconst warnedFieldErrorCodes = new Set<string>();\n\n/**\n * Make an unrecognised `code` visible without admitting it into the union.\n *\n * Dropping the entry is correct (see {@link readFieldErrors}), but a silent\n * drop is indistinguishable from \"the server named no field\": a client one\n * release behind a server that added a code loses every inline form error\n * site-wide with nothing anywhere to explain it.\n *\n * `console.warn` is the least intrusive mechanism that a support engineer can\n * actually act on. It needs no change to the public type, so no consumer has\n * to opt in to see it; it lands in the browser devtools console in a client\n * component and on stdout of the Node process (and therefore in whatever log\n * aggregation already exists) in a Server Component. The alternatives were\n * worse: throwing turns a cosmetic degradation into an outage, admitting the\n * code breaks the closed union this drop exists to protect, and a new\n * `fieldsDropped` flag on the union is invisible until somebody thinks to read\n * it, which is the same silence in a new place.\n */\nfunction warnUnknownFieldErrorCode(code: string, field: string): void {\n if (warnedFieldErrorCodes.has(code)) return;\n warnedFieldErrorCodes.add(code);\n\n console.warn(\n `[@rustrak/client] Dropped a field error with an unrecognised code ` +\n `\"${code}\" (field \"${field}\"). This client build does not know that ` +\n `code, so the affected inputs degrade to a form-level error. The ` +\n `server is most likely newer than @rustrak/client; upgrade the client ` +\n `to match the server version.`,\n );\n}\n\n/**\n * Read `error.fields` out of an `AppError` body.\n *\n * Returns `undefined` rather than `[]` when there is nothing usable, so the\n * key stays absent on the `RustrakError` and a consumer can test for it.\n *\n * Entries are validated one by one and a malformed or unrecognised one is\n * dropped, never coerced. `FieldErrorCode` is a closed union: admitting a code\n * this build has never heard of would hand a consumer a value its own\n * exhaustive `switch` cannot see. A newer server adding a code therefore\n * degrades to a form-level error here, which is the same place an older client\n * already lands, and is announced once via\n * {@link warnUnknownFieldErrorCode} so the skew is not silent.\n *\n * `message` survives only alongside `code: 'custom'`. The server enforces the\n * same rule at construction, but the docs on both sides tell consumers to\n * select translated copy from `(field, code)` for every other code, so a\n * `message` that reaches them there is untranslatable English a careless\n * consumer would render. Dropping it here makes the rule true of the value, not\n * only of its producer.\n */\n/**\n * Read `error.incident_id` out of a 5xx `AppError` body.\n *\n * The one thing a 500 is willing to say about itself. `error_response` replaced\n * its `Display` with a fixed string so a `sqlx::Error`'s constraint and column\n * names stay off the wire (gh-233), and logged the detail under this id; a user\n * quoting it is what lets an operator find that line.\n *\n * Returns `undefined`, never `null` or `''`, for the three bodies that carry no\n * id: a proxy-generated 502/503, a server older than gh-233, and a 500 whose\n * body is not JSON at all. The caller omits the key entirely in that case, so\n * `'incidentId' in error` stays an honest test.\n */\nfunction readIncidentId(body: unknown): string | undefined {\n const raw =\n body && typeof body === 'object'\n ? (body as { error?: { incident_id?: unknown } }).error?.incident_id\n : undefined;\n\n return typeof raw === 'string' && raw.length > 0 ? raw : undefined;\n}\n\nfunction readFieldErrors(body: unknown): FieldError[] | undefined {\n const raw =\n body && typeof body === 'object'\n ? (body as { error?: { fields?: unknown } }).error?.fields\n : undefined;\n\n if (!Array.isArray(raw)) {\n return undefined;\n }\n\n const fields = raw.flatMap((entry) => readFieldError(entry) ?? []);\n return fields.length > 0 ? fields : undefined;\n}\n\n/** One entry of `error.fields`, or `undefined` when it is unusable. */\nfunction readFieldError(entry: unknown): FieldError | undefined {\n if (typeof entry !== 'object' || entry === null) return undefined;\n\n const { field, code, message } = entry as Record<string, unknown>;\n if (typeof field !== 'string') return undefined;\n if (!isFieldErrorCode(code)) {\n if (typeof code === 'string') warnUnknownFieldErrorCode(code, field);\n return undefined;\n }\n\n return code === 'custom' && typeof message === 'string'\n ? { field, code, message }\n : { field, code };\n}\n\n/**\n * `Retry-After` in seconds, when the server sent a parseable one.\n *\n * RFC 9110 §10.2.3 allows two forms and a proxy in front of the Rust process\n * may send either. `Number.parseInt` read only the first, and read it too\n * loosely: it dropped the HTTP-date form entirely as `NaN`, and it returned\n * `-5` from `-5` and `3` from `3abc`, both of which a consumer would pass\n * straight to `setTimeout`.\n *\n * A date already in the past yields `0`, meaning \"retry now\", which is the\n * literal reading of the header.\n */\nfunction readRetryAfter(response: Response): number | undefined {\n const header = response.headers.get('Retry-After')?.trim();\n if (!header) {\n return undefined;\n }\n\n // delta-seconds: digits only. No sign, no trailing junk.\n if (/^\\d+$/.test(header)) {\n return Number.parseInt(header, 10);\n }\n\n // A signed number is neither a valid delta-seconds nor an HTTP-date, and it\n // must not reach `Date.parse`: V8 reads `-5` as a year and would turn a\n // nonsense header into \"retry now\".\n if (/^[+-]\\d+$/.test(header)) {\n return undefined;\n }\n\n const date = Date.parse(header);\n if (Number.isNaN(date)) {\n return undefined;\n }\n\n return Math.max(0, Math.ceil((date - Date.now()) / 1000));\n}\n\n/**\n * Attach `fields` to an error, or leave the key off entirely.\n *\n * The key is omitted rather than set to `undefined` so `'fields' in error`\n * stays an honest test and the object round-trips through `structuredClone`\n * with the same shape it was built with.\n */\nfunction annotate<E extends RustrakError & { fields?: readonly FieldError[] }>(\n base: E,\n fields: readonly FieldError[] | undefined,\n): E {\n return fields === undefined ? base : { ...base, fields };\n}\n\n/**\n * Map an HTTP status onto a `RustrakError` kind.\n *\n * Total by construction: every 5xx redacts, every other unanticipated status\n * lands on `client_error`, so no status is unrepresentable.\n */\nexport function transformHttpError(\n response: Response,\n body: unknown,\n): RustrakError {\n const status = response.status;\n\n // 5xx never carries a server-supplied message. The server redacts its own\n // `Display` now (gh-233), but this stays: a 502 from a reverse proxy and a\n // server older than that fix both still send prose, and every consumer of\n // this client renders `error.message` somewhere. Discard it here, once,\n // rather than trusting 128 call sites to remember.\n //\n // `incidentId` is the exception, and the reason the redaction is no longer\n // lossy: it names the log line carrying what the body dropped, and is\n // omitted entirely when the body has none.\n if (status >= 500) {\n const base = {\n kind: 'server_error',\n status,\n message: SERVER_ERROR_MESSAGE,\n } as const;\n const incidentId = readIncidentId(body);\n\n return incidentId === undefined ? base : { ...base, incidentId };\n }\n\n const message = readErrorMessage(body, status);\n\n // Read once, for every non-5xx status. `AppError::with_field` works on all\n // eight Rust variants, so restricting this to 400 and 409 meant an\n // annotation added to, say, the `Forbidden` in `routes/team.rs` would be\n // serialised, documented in `openapi.json`, and invisible to every consumer\n // with no test failing anywhere. It costs nothing when the key is absent:\n // `readFieldErrors` returns `undefined` without allocating.\n const fields = readFieldErrors(body);\n\n switch (status) {\n case 400:\n // The key is omitted rather than set to `undefined` so that\n // `'fields' in error` stays an honest test, matching `retryAfter` below.\n return annotate({ kind: 'validation', status, message }, fields);\n case 401:\n return annotate({ kind: 'unauthenticated', status, message }, fields);\n case 403:\n return annotate({ kind: 'forbidden', status, message }, fields);\n case 404:\n return annotate({ kind: 'not_found', status, message }, fields);\n case 409:\n return annotate({ kind: 'conflict', status, message }, fields);\n case 410:\n return annotate({ kind: 'gone', status, message }, fields);\n case 413:\n return annotate({ kind: 'payload_too_large', status, message }, fields);\n case 429: {\n const retryAfter = readRetryAfter(response);\n return annotate(\n retryAfter === undefined\n ? { kind: 'rate_limited', status, message }\n : { kind: 'rate_limited', status, message, retryAfter },\n fields,\n );\n }\n default:\n return annotate({ kind: 'client_error', status, message }, fields);\n }\n}\n\n/** Statuses a `Response` may not be constructed with a body for. */\nconst NULL_BODY = new Set([101, 103, 204, 205, 304]);\n\n/** Statuses worth repeating: the request may well succeed a moment later. */\nconst RETRY_STATUSES = new Set([408, 500, 502, 503, 504]);\n\n/**\n * Only methods that are safe to send twice. A `POST` that timed out at the\n * proxy may still have created the row, and a second one creates another.\n */\nconst RETRY_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE']);\n\n/**\n * The `fetch` every request goes through: timeout, retries, and the line\n * between a transport failure and a bug.\n *\n * The body is read here, inside the same `try` as the request, so a\n * connection that dies halfway through a body is a transport failure like\n * one that never connected, not an error the generated client half-parses.\n *\n * Retries wait 300ms, then 600ms, and so on. A caller's own `AbortSignal`\n * aborting is not a transport failure: it rethrows as the `AbortError` the\n * caller asked for.\n */\nexport function createFetch(config: ClientConfig): typeof fetch {\n const timeout = config.timeout ?? 30_000;\n const maxRetries = config.maxRetries ?? 2;\n\n return async (input, init) => {\n const request = new Request(input, init);\n const retries = RETRY_METHODS.has(request.method) ? maxRetries : 0;\n\n for (let n = 0; ; n++) {\n const outcome = await attempt(request, timeout);\n // A timeout is not repeated: the server may still be working on it.\n const retryable =\n outcome instanceof Response\n ? RETRY_STATUSES.has(outcome.status)\n : outcome.reason === 'unreachable';\n if (n < retries && retryable) {\n await sleep(n);\n continue;\n }\n if (outcome instanceof Response) return outcome;\n throw new RustrakTransportFailure(outcome);\n }\n };\n}\n\n/** One try: the response with its body read, or the failure it ended in. */\nasync function attempt(\n request: Request,\n timeout: number,\n): Promise<Response | Extract<RustrakError, { kind: 'network' }>> {\n try {\n const response = await fetch(request.clone(), {\n signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeout)]),\n });\n const body = await response.arrayBuffer();\n // The generated client returns `{}` unvalidated for `Content-Length: 0`.\n // Without it an empty 200 is parsed, and validated, like any other.\n const headers = new Headers(response.headers);\n headers.delete('Content-Length');\n return new Response(NULL_BODY.has(response.status) ? null : body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n } catch (error) {\n if (request.signal.aborted) throw error;\n // No `cause`, and never `error.message`: both embed the resolved host and\n // port, which must not ride along into a serialized payload or a UI.\n return error instanceof DOMException && error.name === 'TimeoutError'\n ? { kind: 'network', message: TIMEOUT_ERROR_MESSAGE, reason: 'timeout' }\n : {\n kind: 'network',\n message: NETWORK_ERROR_MESSAGE,\n reason: 'unreachable',\n };\n }\n}\n\nfunction sleep(attempt: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rustrak/client",
3
- "version": "0.18.1",
3
+ "version": "0.19.1",
4
4
  "description": "Type-safe TypeScript client for the Rustrak self-hosted error tracking API",
5
5
  "homepage": "https://rustrak.dev/sdks/client",
6
6
  "repository": {
@@ -16,14 +16,15 @@
16
16
  },
17
17
  "type": "module",
18
18
  "main": "./dist/index.cjs",
19
- "module": "./dist/index.js",
20
- "types": "./dist/index.d.ts",
19
+ "module": "./dist/index.mjs",
20
+ "types": "./dist/index.d.cts",
21
+ "sideEffects": false,
21
22
  "exports": {
22
23
  ".": {
23
- "types": "./dist/index.d.ts",
24
- "import": "./dist/index.js",
24
+ "import": "./dist/index.mjs",
25
25
  "require": "./dist/index.cjs"
26
- }
26
+ },
27
+ "./package.json": "./package.json"
27
28
  },
28
29
  "files": [
29
30
  "dist",
@@ -46,24 +47,28 @@
46
47
  "node": ">=20"
47
48
  },
48
49
  "dependencies": {
49
- "ky": "2.2.0",
50
50
  "zod": "4.6.7"
51
51
  },
52
52
  "devDependencies": {
53
+ "@hey-api/openapi-ts": "0.99.0",
53
54
  "@types/node": "26.6.5",
54
- "@vitest/coverage-v8": "5.0.4",
55
+ "@vitest/coverage-v8": "5.0.5",
55
56
  "msw": "3.0.3",
56
- "tsup": "8.5.1",
57
+ "tsdown": "0.23.1",
57
58
  "typescript": "6.0.3",
58
- "vitest": "5.0.4"
59
+ "vitest": "5.0.5"
59
60
  },
60
61
  "scripts": {
61
- "build": "tsup",
62
- "dev": "tsup --watch",
62
+ "build": "tsdown",
63
+ "dev": "tsdown --watch",
64
+ "generate": "openapi-ts && node scripts/generate-readme.ts",
65
+ "generate:check": "RUSTRAK_CLIENT_OUT=node_modules/.cache/generated-check openapi-ts && diff -r node_modules/.cache/generated-check src/generated && node scripts/generate-readme.ts --check",
63
66
  "test": "vitest run",
64
67
  "test:watch": "vitest",
65
68
  "test:coverage": "vitest run --coverage",
66
69
  "check-types": "tsc --noEmit",
70
+ "lint": "biome check .",
71
+ "format:check": "biome format .",
67
72
  "clean": "rm -rf dist"
68
73
  }
69
74
  }