@rustrak/client 0.25.0 → 0.26.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.
Files changed (56) hide show
  1. package/README.md +47 -48
  2. package/dist/client.cjs +4 -3
  3. package/dist/client.cjs.map +1 -1
  4. package/dist/client.d.cts +2 -1
  5. package/dist/client.d.cts.map +1 -1
  6. package/dist/client.d.mts +2 -1
  7. package/dist/client.d.mts.map +1 -1
  8. package/dist/client.mjs +5 -4
  9. package/dist/client.mjs.map +1 -1
  10. package/dist/config.d.cts.map +1 -1
  11. package/dist/config.d.mts.map +1 -1
  12. package/dist/errors.cjs +19 -6
  13. package/dist/errors.cjs.map +1 -1
  14. package/dist/errors.d.cts +1 -1
  15. package/dist/errors.d.cts.map +1 -1
  16. package/dist/errors.d.mts +1 -1
  17. package/dist/errors.d.mts.map +1 -1
  18. package/dist/errors.mjs +19 -7
  19. package/dist/errors.mjs.map +1 -1
  20. package/dist/generated/binary.gen.cjs +6 -0
  21. package/dist/generated/binary.gen.cjs.map +1 -0
  22. package/dist/generated/binary.gen.d.cts +2 -0
  23. package/dist/generated/binary.gen.d.cts.map +1 -0
  24. package/dist/generated/binary.gen.d.mts +2 -0
  25. package/dist/generated/binary.gen.d.mts.map +1 -0
  26. package/dist/generated/binary.gen.mjs +6 -0
  27. package/dist/generated/binary.gen.mjs.map +1 -0
  28. package/dist/generated/sdk.gen.cjs +96 -187
  29. package/dist/generated/sdk.gen.cjs.map +1 -1
  30. package/dist/generated/sdk.gen.d.cts +1 -2
  31. package/dist/generated/sdk.gen.d.cts.map +1 -1
  32. package/dist/generated/sdk.gen.d.mts +1 -2
  33. package/dist/generated/sdk.gen.d.mts.map +1 -1
  34. package/dist/generated/sdk.gen.mjs +97 -188
  35. package/dist/generated/sdk.gen.mjs.map +1 -1
  36. package/dist/generated/types.gen.d.cts +3 -21
  37. package/dist/generated/types.gen.d.cts.map +1 -1
  38. package/dist/generated/types.gen.d.mts +3 -21
  39. package/dist/generated/types.gen.d.mts.map +1 -1
  40. package/dist/generated/zod.gen.cjs +2 -17
  41. package/dist/generated/zod.gen.cjs.map +1 -1
  42. package/dist/generated/zod.gen.mjs +3 -16
  43. package/dist/generated/zod.gen.mjs.map +1 -1
  44. package/dist/index.d.cts +2 -2
  45. package/dist/index.d.mts +2 -2
  46. package/dist/result.cjs +0 -2
  47. package/dist/result.cjs.map +1 -1
  48. package/dist/result.d.cts.map +1 -1
  49. package/dist/result.d.mts.map +1 -1
  50. package/dist/result.mjs +0 -2
  51. package/dist/result.mjs.map +1 -1
  52. package/dist/utils/http.cjs +18 -31
  53. package/dist/utils/http.cjs.map +1 -1
  54. package/dist/utils/http.mjs +20 -32
  55. package/dist/utils/http.mjs.map +1 -1
  56. package/package.json +3 -5
@@ -1 +1 @@
1
- {"version":3,"file":"result.mjs","names":[],"sources":["../src/result.ts"],"sourcesContent":["/**\n * `Result` is how `@rustrak/client` reports an expected failure.\n *\n * The shape deliberately mirrors Zod's `safeParse`: discriminant `success`,\n * payload `data`. Everything inside is a plain object with `Object.prototype`,\n * no class instances and no methods, because React's Flight serializer refuses\n * to send anything else across the server/client boundary. Operations are\n * standalone functions for the same reason.\n */\nexport type Result<T, E> =\n | { readonly success: true; readonly data: T }\n | { readonly success: false; readonly error: E };\n\n/** Wrap a value as a successful `Result`. */\nexport function Ok<T>(data: T): Result<T, never> {\n return { success: true, data };\n}\n\n/** Wrap an error as a failed `Result`. */\nexport function Err<E>(error: E): Result<never, E> {\n return { success: false, error };\n}\n\n/**\n * Get the value, throwing if the `Result` is a failure.\n *\n * This is the caller explicitly opting back into exceptions. Nothing inside the\n * client calls it.\n */\nexport function unwrap<T, E>(result: Result<T, E>): T {\n if (result.success) {\n return result.data;\n }\n throw new Error(\n `unwrap() called on a failed Result: ${describe(result.error)}`,\n );\n}\n\n/**\n * Get the value, or `fallback` if the `Result` is a failure.\n *\n * **Do not use this to render a UI.** `unwrapOr(await client.projects.list(),\n * [])` collapses \"this account has no projects\" and \"the server is unreachable\"\n * into the same empty state, which is exactly the regression the `Result` API\n * exists to make impossible: the failure is discarded one line after it was\n * produced, and nothing downstream can tell there was one.\n *\n * It is appropriate where the fallback is genuinely correct regardless of why\n * the call failed: a cached-count optimisation, a best-effort telemetry read, a\n * script that already logged the error. Anywhere a human sees the outcome,\n * branch on `result.success` and read `error.kind`.\n */\nexport function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {\n return result.success ? result.data : fallback;\n}\n\n/** Apply `fn` to the value of a successful `Result`, passing failures through. */\nexport function mapResult<T, U, E>(\n result: Result<T, E>,\n fn: (value: T) => U,\n): Result<U, E> {\n return result.success ? Ok(fn(result.data)) : result;\n}\n\n/** Best-effort rendering of an unknown error value for `unwrap`'s message. */\nfunction describe(error: unknown): string {\n if (\n typeof error === 'object' &&\n error !== null &&\n 'message' in error &&\n typeof (error as { message: unknown }).message === 'string'\n ) {\n return (error as { message: string }).message;\n }\n return String(error);\n}\n"],"mappings":";;AAcA,SAAgB,GAAM,MAA2B;CAC/C,OAAO;EAAE,SAAS;EAAM;CAAK;AAC/B;;AAGA,SAAgB,IAAO,OAA4B;CACjD,OAAO;EAAE,SAAS;EAAO;CAAM;AACjC;;;;;;;AAQA,SAAgB,OAAa,QAAyB;CACpD,IAAI,OAAO,SACT,OAAO,OAAO;CAEhB,MAAM,IAAI,MACR,uCAAuC,SAAS,OAAO,KAAK,GAC9D;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,SAAe,QAAsB,UAAgB;CACnE,OAAO,OAAO,UAAU,OAAO,OAAO;AACxC;;AAGA,SAAgB,UACd,QACA,IACc;CACd,OAAO,OAAO,UAAU,GAAG,GAAG,OAAO,IAAI,CAAC,IAAI;AAChD;;AAGA,SAAS,SAAS,OAAwB;CACxC,IACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,OAAQ,MAA+B,YAAY,UAEnD,OAAQ,MAA8B;CAExC,OAAO,OAAO,KAAK;AACrB"}
1
+ {"version":3,"file":"result.mjs","names":[],"sources":["../src/result.ts"],"sourcesContent":["/**\n * `Result` is how `@rustrak/client` reports an expected failure.\n *\n * The shape deliberately mirrors Zod's `safeParse`: discriminant `success`,\n * payload `data`. Everything inside is a plain object with `Object.prototype`,\n * no class instances and no methods, because React's Flight serializer refuses\n * to send anything else across the server/client boundary.\n */\nexport type Result<T, E> =\n | { readonly success: true; readonly data: T }\n | { readonly success: false; readonly error: E };\n\nexport function Ok<T>(data: T): Result<T, never> {\n return { success: true, data };\n}\n\nexport function Err<E>(error: E): Result<never, E> {\n return { success: false, error };\n}\n\n/**\n * Get the value, throwing if the `Result` is a failure.\n *\n * This is the caller explicitly opting back into exceptions. Nothing inside the\n * client calls it.\n */\nexport function unwrap<T, E>(result: Result<T, E>): T {\n if (result.success) {\n return result.data;\n }\n throw new Error(\n `unwrap() called on a failed Result: ${describe(result.error)}`,\n );\n}\n\n/**\n * Get the value, or `fallback` if the `Result` is a failure.\n *\n * **Do not use this to render a UI.** `unwrapOr(await client.projects.list(),\n * [])` collapses \"this account has no projects\" and \"the server is unreachable\"\n * into the same empty state, which is exactly the regression the `Result` API\n * exists to make impossible: the failure is discarded one line after it was\n * produced, and nothing downstream can tell there was one.\n *\n * It is appropriate where the fallback is genuinely correct regardless of why\n * the call failed: a cached-count optimisation, a best-effort telemetry read, a\n * script that already logged the error. Anywhere a human sees the outcome,\n * branch on `result.success` and read `error.kind`.\n */\nexport function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {\n return result.success ? result.data : fallback;\n}\n\n/** Apply `fn` to the value of a successful `Result`, passing failures through. */\nexport function mapResult<T, U, E>(\n result: Result<T, E>,\n fn: (value: T) => U,\n): Result<U, E> {\n return result.success ? Ok(fn(result.data)) : result;\n}\n\n/** Best-effort rendering of an unknown error value for `unwrap`'s message. */\nfunction describe(error: unknown): string {\n if (\n typeof error === 'object' &&\n error !== null &&\n 'message' in error &&\n typeof (error as { message: unknown }).message === 'string'\n ) {\n return (error as { message: string }).message;\n }\n return String(error);\n}\n"],"mappings":";AAYA,SAAgB,GAAM,MAA2B;CAC/C,OAAO;EAAE,SAAS;EAAM;CAAK;AAC/B;AAEA,SAAgB,IAAO,OAA4B;CACjD,OAAO;EAAE,SAAS;EAAO;CAAM;AACjC;;;;;;;AAQA,SAAgB,OAAa,QAAyB;CACpD,IAAI,OAAO,SACT,OAAO,OAAO;CAEhB,MAAM,IAAI,MACR,uCAAuC,SAAS,OAAO,KAAK,GAC9D;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,SAAe,QAAsB,UAAgB;CACnE,OAAO,OAAO,UAAU,OAAO,OAAO;AACxC;;AAGA,SAAgB,UACd,QACA,IACc;CACd,OAAO,OAAO,UAAU,GAAG,GAAG,OAAO,IAAI,CAAC,IAAI;AAChD;;AAGA,SAAS,SAAS,OAAwB;CACxC,IACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,OAAQ,MAA+B,YAAY,UAEnD,OAAQ,MAA8B;CAExC,OAAO,OAAO,KAAK;AACrB"}
@@ -18,10 +18,6 @@ var RustrakTransportFailure = class extends Error {
18
18
  this.rustrakError = error;
19
19
  }
20
20
  };
21
- /** Narrow a caught value to the internal carrier. */
22
- function isTransportFailure(value) {
23
- return value instanceof RustrakTransportFailure;
24
- }
25
21
  /**
26
22
  * Read the human-readable message out of an error body.
27
23
  *
@@ -73,6 +69,23 @@ function warnUnknownFieldErrorCode(code, field) {
73
69
  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
70
  }
75
71
  /**
72
+ * Read `error.incident_id` out of a 5xx `AppError` body.
73
+ *
74
+ * The one thing a 500 is willing to say about itself. `error_response` replaced
75
+ * its `Display` with a fixed string so a `sqlx::Error`'s constraint and column
76
+ * names stay off the wire (gh-233), and logged the detail under this id; a user
77
+ * quoting it is what lets an operator find that line.
78
+ *
79
+ * Returns `undefined`, never `null` or `''`, for the three bodies that carry no
80
+ * id: a proxy-generated 502/503, a server older than gh-233, and a 500 whose
81
+ * body is not JSON at all. The caller omits the key entirely in that case, so
82
+ * `'incidentId' in error` stays an honest test.
83
+ */
84
+ function readIncidentId(body) {
85
+ const raw = body && typeof body === "object" ? body.error?.incident_id : void 0;
86
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
87
+ }
88
+ /**
76
89
  * Read `error.fields` out of an `AppError` body.
77
90
  *
78
91
  * Returns `undefined` rather than `[]` when there is nothing usable, so the
@@ -93,23 +106,6 @@ function warnUnknownFieldErrorCode(code, field) {
93
106
  * consumer would render. Dropping it here makes the rule true of the value, not
94
107
  * only of its producer.
95
108
  */
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
109
  function readFieldErrors(body) {
114
110
  const raw = body && typeof body === "object" ? body.error?.fields : void 0;
115
111
  if (!Array.isArray(raw)) return;
@@ -254,14 +250,6 @@ const NULL_BODY = /* @__PURE__ */ new Set([
254
250
  205,
255
251
  304
256
252
  ]);
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
253
  /**
266
254
  * Only methods that are safe to send twice. A `POST` that timed out at the
267
255
  * proxy may still have created the row, and a second one creates another.
@@ -292,7 +280,7 @@ function createFetch(config) {
292
280
  const retries = RETRY_METHODS.has(request.method) ? maxRetries : 0;
293
281
  for (let n = 0;; n++) {
294
282
  const outcome = await attempt(request, timeout);
295
- const retryable = outcome instanceof Response ? RETRY_STATUSES.has(outcome.status) : outcome.reason === "unreachable";
283
+ const retryable = outcome instanceof Response ? outcome.status !== 429 && require_errors.TRANSIENT_STATUSES.has(outcome.status) : outcome.reason === "unreachable";
296
284
  if (n < retries && retryable) {
297
285
  await sleep(n);
298
286
  continue;
@@ -332,7 +320,6 @@ function sleep(attempt) {
332
320
  }
333
321
  exports.RustrakTransportFailure = RustrakTransportFailure;
334
322
  exports.createFetch = createFetch;
335
- exports.isTransportFailure = isTransportFailure;
336
323
  exports.transformHttpError = transformHttpError;
337
324
 
338
325
  //# sourceMappingURL=http.cjs.map
@@ -1 +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":";;;;;;;;;;;;AAqBA,IAAa,0BAAb,cAA6C,MAAM;CACjD;CAEA,YAAY,OAAqB;EAC/B,MAAM,MAAM,OAAO;EACnB,KAAK,OAAO;EACZ,KAAK,eAAe;CACtB;AACF;;AAGA,SAAgB,mBACd,OACkC;CAClC,OAAO,iBAAiB;AAC1B;;;;;;;;AASA,SAAS,iBAAiB,MAAe,QAAwB;CAC/D,MAAM,WAAW,QAAQ,OAAO;CAMhC,IAAI,QAAQ,OAAO,SAAS,UAAU;EACpC,MAAM,EAAE,OAAO,YAAY;EAK3B,QADe,OAAO,UAAU,WAAW,OAAO,UAAU,UAC3C,WAAW;CAC9B;CAEA,OAAO;AACT;AAEA,MAAM,0BAA0B,IAAI,IAAYA,eAAAA,iBAAiB;AAEjE,SAAS,iBAAiB,OAAyC;CACjE,OAAO,OAAO,UAAU,YAAY,wBAAwB,IAAI,KAAK;AACvE;;;;;;;;AASA,MAAM,wCAAwB,IAAI,IAAY;;;;;;;;;;;;;;;;;;;AAoB9C,SAAS,0BAA0B,MAAc,OAAqB;CACpE,IAAI,sBAAsB,IAAI,IAAI,GAAG;CACrC,sBAAsB,IAAI,IAAI;CAE9B,QAAQ,KACN,sEACM,KAAK,YAAY,MAAM,2MAI/B;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCA,SAAS,eAAe,MAAmC;CACzD,MAAM,MACJ,QAAQ,OAAO,SAAS,WACnB,KAA+C,OAAO,cACvD,KAAA;CAEN,OAAO,OAAO,QAAQ,YAAY,IAAI,SAAS,IAAI,MAAM,KAAA;AAC3D;AAEA,SAAS,gBAAgB,MAAyC;CAChE,MAAM,MACJ,QAAQ,OAAO,SAAS,WACnB,KAA0C,OAAO,SAClD,KAAA;CAEN,IAAI,CAAC,MAAM,QAAQ,GAAG,GACpB;CAGF,MAAM,SAAS,IAAI,SAAS,UAAU,eAAe,KAAK,KAAK,CAAC,CAAC;CACjE,OAAO,OAAO,SAAS,IAAI,SAAS,KAAA;AACtC;;AAGA,SAAS,eAAe,OAAwC;CAC9D,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO,KAAA;CAExD,MAAM,EAAE,OAAO,MAAM,YAAY;CACjC,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;CACtC,IAAI,CAAC,iBAAiB,IAAI,GAAG;EAC3B,IAAI,OAAO,SAAS,UAAU,0BAA0B,MAAM,KAAK;EACnE;CACF;CAEA,OAAO,SAAS,YAAY,OAAO,YAAY,WAC3C;EAAE;EAAO;EAAM;CAAQ,IACvB;EAAE;EAAO;CAAK;AACpB;;;;;;;;;;;;;AAcA,SAAS,eAAe,UAAwC;CAC9D,MAAM,SAAS,SAAS,QAAQ,IAAI,aAAa,CAAC,EAAE,KAAK;CACzD,IAAI,CAAC,QACH;CAIF,IAAI,QAAQ,KAAK,MAAM,GACrB,OAAO,OAAO,SAAS,QAAQ,EAAE;CAMnC,IAAI,YAAY,KAAK,MAAM,GACzB;CAGF,MAAM,OAAO,KAAK,MAAM,MAAM;CAC9B,IAAI,OAAO,MAAM,IAAI,GACnB;CAGF,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,KAAK,IAAI,KAAK,GAAI,CAAC;AAC1D;;;;;;;;AASA,SAAS,SACP,MACA,QACG;CACH,OAAO,WAAW,KAAA,IAAY,OAAO;EAAE,GAAG;EAAM;CAAO;AACzD;;;;;;;AAQA,SAAgB,mBACd,UACA,MACc;CACd,MAAM,SAAS,SAAS;CAWxB,IAAI,UAAU,KAAK;EACjB,MAAM,OAAO;GACX,MAAM;GACN;GACA,SAASC,eAAAA;EACX;EACA,MAAM,aAAa,eAAe,IAAI;EAEtC,OAAO,eAAe,KAAA,IAAY,OAAO;GAAE,GAAG;GAAM;EAAW;CACjE;CAEA,MAAM,UAAU,iBAAiB,MAAM,MAAM;CAQ7C,MAAM,SAAS,gBAAgB,IAAI;CAEnC,QAAQ,QAAR;EACE,KAAK,KAGH,OAAO,SAAS;GAAE,MAAM;GAAc;GAAQ;EAAQ,GAAG,MAAM;EACjE,KAAK,KACH,OAAO,SAAS;GAAE,MAAM;GAAmB;GAAQ;EAAQ,GAAG,MAAM;EACtE,KAAK,KACH,OAAO,SAAS;GAAE,MAAM;GAAa;GAAQ;EAAQ,GAAG,MAAM;EAChE,KAAK,KACH,OAAO,SAAS;GAAE,MAAM;GAAa;GAAQ;EAAQ,GAAG,MAAM;EAChE,KAAK,KACH,OAAO,SAAS;GAAE,MAAM;GAAY;GAAQ;EAAQ,GAAG,MAAM;EAC/D,KAAK,KACH,OAAO,SAAS;GAAE,MAAM;GAAQ;GAAQ;EAAQ,GAAG,MAAM;EAC3D,KAAK,KACH,OAAO,SAAS;GAAE,MAAM;GAAqB;GAAQ;EAAQ,GAAG,MAAM;EACxE,KAAK,KAAK;GACR,MAAM,aAAa,eAAe,QAAQ;GAC1C,OAAO,SACL,eAAe,KAAA,IACX;IAAE,MAAM;IAAgB;IAAQ;GAAQ,IACxC;IAAE,MAAM;IAAgB;IAAQ;IAAS;GAAW,GACxD,MACF;EACF;EACA,SACE,OAAO,SAAS;GAAE,MAAM;GAAgB;GAAQ;EAAQ,GAAG,MAAM;CACrE;AACF;;AAGA,MAAM,4BAAY,IAAI,IAAI;CAAC;CAAK;CAAK;CAAK;CAAK;AAAG,CAAC;;AAGnD,MAAM,iCAAiB,IAAI,IAAI;CAAC;CAAK;CAAK;CAAK;CAAK;AAAG,CAAC;;;;;AAMxD,MAAM,gCAAgB,IAAI,IAAI;CAAC;CAAO;CAAQ;CAAO;AAAQ,CAAC;;;;;;;;;;;;;AAc9D,SAAgB,YAAY,QAAoC;CAC9D,MAAM,UAAU,OAAO,WAAW;CAClC,MAAM,aAAa,OAAO,cAAc;CAExC,OAAO,OAAO,OAAO,SAAS;EAC5B,MAAM,UAAU,IAAI,QAAQ,OAAO,IAAI;EACvC,MAAM,UAAU,cAAc,IAAI,QAAQ,MAAM,IAAI,aAAa;EAEjE,KAAK,IAAI,IAAI,IAAK,KAAK;GACrB,MAAM,UAAU,MAAM,QAAQ,SAAS,OAAO;GAE9C,MAAM,YACJ,mBAAmB,WACf,eAAe,IAAI,QAAQ,MAAM,IACjC,QAAQ,WAAW;GACzB,IAAI,IAAI,WAAW,WAAW;IAC5B,MAAM,MAAM,CAAC;IACb;GACF;GACA,IAAI,mBAAmB,UAAU,OAAO;GACxC,MAAM,IAAI,wBAAwB,OAAO;EAC3C;CACF;AACF;;AAGA,eAAe,QACb,SACA,SACgE;CAChE,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,QAAQ,MAAM,GAAG,EAC5C,QAAQ,YAAY,IAAI,CAAC,QAAQ,QAAQ,YAAY,QAAQ,OAAO,CAAC,CAAC,EACxE,CAAC;EACD,MAAM,OAAO,MAAM,SAAS,YAAY;EAGxC,MAAM,UAAU,IAAI,QAAQ,SAAS,OAAO;EAC5C,QAAQ,OAAO,gBAAgB;EAC/B,OAAO,IAAI,SAAS,UAAU,IAAI,SAAS,MAAM,IAAI,OAAO,MAAM;GAChE,QAAQ,SAAS;GACjB,YAAY,SAAS;GACrB;EACF,CAAC;CACH,SAAS,OAAO;EACd,IAAI,QAAQ,OAAO,SAAS,MAAM;EAGlC,OAAO,iBAAiB,gBAAgB,MAAM,SAAS,iBACnD;GAAE,MAAM;GAAW,SAASC,eAAAA;GAAuB,QAAQ;EAAU,IACrE;GACE,MAAM;GACN,SAASC,eAAAA;GACT,QAAQ;EACV;CACN;AACF;AAEA,SAAS,MAAM,SAAgC;CAC7C,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,MAAM,KAAK,OAAO,CAAC;AACzE"}
1
+ {"version":3,"file":"http.cjs","names":["FIELD_ERROR_CODES","SERVER_ERROR_MESSAGE","TRANSIENT_STATUSES","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 TRANSIENT_STATUSES,\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/**\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.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\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 */\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 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/**\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 // `isRetryable`'s rule, less the two failures whose wait only the\n // caller can pick: a 429 names its own in `Retry-After`, and a request\n // that timed out may still be running on the server.\n const retryable =\n outcome instanceof Response\n ? outcome.status !== 429 && TRANSIENT_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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"}
@@ -1,4 +1,4 @@
1
- import { FIELD_ERROR_CODES, NETWORK_ERROR_MESSAGE, SERVER_ERROR_MESSAGE, TIMEOUT_ERROR_MESSAGE } from "../errors.mjs";
1
+ import { FIELD_ERROR_CODES, NETWORK_ERROR_MESSAGE, SERVER_ERROR_MESSAGE, TIMEOUT_ERROR_MESSAGE, TRANSIENT_STATUSES } from "../errors.mjs";
2
2
  // src/utils/http.ts
3
3
  /**
4
4
  * Internal carrier for a transport failure travelling out of `fetch`.
@@ -18,10 +18,6 @@ var RustrakTransportFailure = class extends Error {
18
18
  this.rustrakError = error;
19
19
  }
20
20
  };
21
- /** Narrow a caught value to the internal carrier. */
22
- function isTransportFailure(value) {
23
- return value instanceof RustrakTransportFailure;
24
- }
25
21
  /**
26
22
  * Read the human-readable message out of an error body.
27
23
  *
@@ -73,6 +69,23 @@ function warnUnknownFieldErrorCode(code, field) {
73
69
  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
70
  }
75
71
  /**
72
+ * Read `error.incident_id` out of a 5xx `AppError` body.
73
+ *
74
+ * The one thing a 500 is willing to say about itself. `error_response` replaced
75
+ * its `Display` with a fixed string so a `sqlx::Error`'s constraint and column
76
+ * names stay off the wire (gh-233), and logged the detail under this id; a user
77
+ * quoting it is what lets an operator find that line.
78
+ *
79
+ * Returns `undefined`, never `null` or `''`, for the three bodies that carry no
80
+ * id: a proxy-generated 502/503, a server older than gh-233, and a 500 whose
81
+ * body is not JSON at all. The caller omits the key entirely in that case, so
82
+ * `'incidentId' in error` stays an honest test.
83
+ */
84
+ function readIncidentId(body) {
85
+ const raw = body && typeof body === "object" ? body.error?.incident_id : void 0;
86
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
87
+ }
88
+ /**
76
89
  * Read `error.fields` out of an `AppError` body.
77
90
  *
78
91
  * Returns `undefined` rather than `[]` when there is nothing usable, so the
@@ -93,23 +106,6 @@ function warnUnknownFieldErrorCode(code, field) {
93
106
  * consumer would render. Dropping it here makes the rule true of the value, not
94
107
  * only of its producer.
95
108
  */
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
109
  function readFieldErrors(body) {
114
110
  const raw = body && typeof body === "object" ? body.error?.fields : void 0;
115
111
  if (!Array.isArray(raw)) return;
@@ -254,14 +250,6 @@ const NULL_BODY = /* @__PURE__ */ new Set([
254
250
  205,
255
251
  304
256
252
  ]);
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
253
  /**
266
254
  * Only methods that are safe to send twice. A `POST` that timed out at the
267
255
  * proxy may still have created the row, and a second one creates another.
@@ -292,7 +280,7 @@ function createFetch(config) {
292
280
  const retries = RETRY_METHODS.has(request.method) ? maxRetries : 0;
293
281
  for (let n = 0;; n++) {
294
282
  const outcome = await attempt(request, timeout);
295
- const retryable = outcome instanceof Response ? RETRY_STATUSES.has(outcome.status) : outcome.reason === "unreachable";
283
+ const retryable = outcome instanceof Response ? outcome.status !== 429 && TRANSIENT_STATUSES.has(outcome.status) : outcome.reason === "unreachable";
296
284
  if (n < retries && retryable) {
297
285
  await sleep(n);
298
286
  continue;
@@ -330,6 +318,6 @@ async function attempt(request, timeout) {
330
318
  function sleep(attempt) {
331
319
  return new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));
332
320
  }
333
- export { RustrakTransportFailure, createFetch, isTransportFailure, transformHttpError };
321
+ export { RustrakTransportFailure, createFetch, transformHttpError };
334
322
 
335
323
  //# sourceMappingURL=http.mjs.map
@@ -1 +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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"}
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 TRANSIENT_STATUSES,\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/**\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.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\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 */\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 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/**\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 // `isRetryable`'s rule, less the two failures whose wait only the\n // caller can pick: a 429 names its own in `Retry-After`, and a request\n // that timed out may still be running on the server.\n const retryable =\n outcome instanceof Response\n ? outcome.status !== 429 && TRANSIENT_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.25.0",
3
+ "version": "0.26.0",
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": {
@@ -60,11 +60,9 @@
60
60
  },
61
61
  "scripts": {
62
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
+ "generate": "openapi-ts && node scripts/generate.ts",
64
+ "generate:check": "export RUSTRAK_CLIENT_OUT=node_modules/.cache/generated-check && openapi-ts && node scripts/generate.ts --check && diff -r $RUSTRAK_CLIENT_OUT src/generated",
66
65
  "test": "vitest run",
67
- "test:watch": "vitest",
68
66
  "test:coverage": "vitest run --coverage",
69
67
  "check-types": "tsc --noEmit",
70
68
  "lint": "biome check .",