stitchkit 0.73.0 → 0.74.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.
package/dist/index.js CHANGED
@@ -86,620 +86,633 @@ function cancellationError(cause) {
86
86
  return new RequestCancellationError(cause);
87
87
  }
88
88
 
89
- // src/browser/client-multipart.ts
90
- function isFileDescriptor(value) {
91
- return typeof value === "object" && value !== null && !(value instanceof Blob) && "uri" in value && typeof value.uri === "string" && "name" in value && typeof value.name === "string" && "type" in value && typeof value.type === "string";
92
- }
93
- function isMultipartFile(value) {
94
- return value instanceof Blob || isFileDescriptor(value);
89
+ // src/browser/http.ts
90
+ import ky, {
91
+ isHTTPError,
92
+ isNetworkError,
93
+ isTimeoutError
94
+ } from "ky";
95
+
96
+ // src/internal/random-hex.ts
97
+ function randomHex(bytes) {
98
+ const arr = new Uint8Array(bytes);
99
+ crypto.getRandomValues(arr);
100
+ let hex = "";
101
+ for (const byte of arr)
102
+ hex += byte.toString(16).padStart(2, "0");
103
+ return hex;
95
104
  }
96
- function appendMultipartFile(form, field, file) {
97
- const sink = form;
98
- sink.append(field, file);
105
+
106
+ // src/observability/trace.ts
107
+ var TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(.*)$/i;
108
+ function createTraceContext() {
109
+ return { traceId: randomHex(16), spanId: randomHex(8) };
99
110
  }
100
- function appendFormFields(formData, values, skipKeys) {
101
- for (const [key, value] of Object.entries(values)) {
102
- if (skipKeys.has(key) || value === undefined || value === null)
103
- continue;
104
- formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
111
+ function parseTraceparent(header) {
112
+ if (!header)
113
+ return null;
114
+ const match = TRACEPARENT_RE.exec(header.trim());
115
+ if (!match?.[1] || !match[2] || !match[3] || !match[4] || match[5] === undefined)
116
+ return null;
117
+ const version = match[1].toLowerCase();
118
+ if (version === "ff")
119
+ return null;
120
+ const suffix = match[5];
121
+ if (version === "00" ? suffix !== "" : suffix !== "" && !/^(?:-[0-9a-f]{2,})+$/i.test(suffix)) {
122
+ return null;
105
123
  }
124
+ const traceId = match[2].toLowerCase();
125
+ const parentSpanId = match[3].toLowerCase();
126
+ const traceFlags = match[4].toLowerCase();
127
+ if (/^0+$/.test(traceId) || /^0+$/.test(parentSpanId))
128
+ return null;
129
+ return { traceId, spanId: randomHex(8), parentSpanId, traceFlags };
106
130
  }
107
- function buildMultipartForm(descriptor, values) {
108
- const formData = new FormData;
109
- const fileFields = new Set(Object.keys(descriptor.files));
110
- for (const [field, policy] of Object.entries(descriptor.files)) {
111
- const value = values[field];
112
- if (value === undefined) {
113
- if (policy.required !== false)
114
- throw new Error(`Missing multipart file field: ${field}`);
115
- continue;
116
- }
117
- if (policy.multiple === true) {
118
- if (!Array.isArray(value) || value.length === 0) {
119
- throw new Error(`Multipart file field "${field}" must be a non-empty array`);
120
- }
121
- for (const file of value) {
122
- if (!isMultipartFile(file)) {
123
- throw new Error(`Invalid multipart file field: ${field}`);
124
- }
125
- appendMultipartFile(formData, field, file);
126
- }
127
- continue;
128
- }
129
- if (!isMultipartFile(value))
130
- throw new Error(`Invalid multipart file field: ${field}`);
131
- appendMultipartFile(formData, field, value);
132
- }
133
- appendFormFields(formData, values, fileFields);
134
- return formData;
131
+ function formatTraceparent(ctx) {
132
+ return `00-${ctx.traceId}-${ctx.spanId}-${ctx.traceFlags ?? "01"}`;
135
133
  }
136
-
137
- // src/internal/http-input.ts
138
- function inputIsQuery(method) {
139
- return method === "GET" || method === "DELETE";
134
+ function childSpan(parent) {
135
+ return {
136
+ traceId: parent.traceId,
137
+ spanId: randomHex(8),
138
+ parentSpanId: parent.spanId,
139
+ ...parent.tracestate !== undefined && { tracestate: parent.tracestate },
140
+ ...parent.baggage !== undefined && { baggage: parent.baggage },
141
+ ...parent.traceFlags !== undefined && { traceFlags: parent.traceFlags }
142
+ };
140
143
  }
144
+ var encoder = new TextEncoder;
141
145
 
142
- // src/browser/client-url.ts
143
- function isParamArray(value) {
144
- return Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number");
146
+ // src/browser/request-id.ts
147
+ var REQUEST_ID_HEADER = "x-request-id";
148
+ function responseTraceId(response) {
149
+ return response?.headers.get(REQUEST_ID_HEADER) ?? undefined;
145
150
  }
146
- function collectQueryParams(args, endpoint) {
147
- const params = {};
148
- let hasParams = false;
149
- for (const [key, value] of Object.entries(args)) {
150
- if (value === undefined || value === null)
151
- continue;
152
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || isParamArray(value)) {
153
- params[key] = value;
154
- hasParams = true;
155
- continue;
156
- }
157
- const what = Array.isArray(value) ? "an array with non-primitive items" : typeof value === "object" ? "a nested object" : `a ${typeof value}`;
158
- throw new Error(`${endpoint.method} ${endpoint.path}: input field "${key}" is ${what} — it cannot ` + "travel as a query parameter. GET / DELETE input must be flat (string / number / " + "boolean, or an array of string / number); flatten the field or move the " + "operation to a body verb (POST).");
159
- }
160
- return hasParams ? params : undefined;
151
+
152
+ // src/browser/http.ts
153
+ var API_ERROR_BRAND = Symbol.for("stitchkit.ApiError");
154
+ function messageForCode(code, message) {
155
+ return message !== undefined && message.length > 0 ? message : `${code} (no message supplied)`;
161
156
  }
162
- function hasStringKeys(args, keys) {
163
- for (const key of keys) {
164
- if (typeof args[key] !== "string")
165
- return false;
157
+
158
+ class ApiError extends Error {
159
+ code;
160
+ status;
161
+ details;
162
+ hint;
163
+ traceId;
164
+ constructor(code, status = 0, details, message, hint, traceId, options) {
165
+ super(messageForCode(code, message), options);
166
+ this.code = code;
167
+ this.status = status;
168
+ this.details = details;
169
+ this.hint = hint;
170
+ this.traceId = traceId;
171
+ this.name = "ApiError";
172
+ Object.defineProperty(this, API_ERROR_BRAND, { value: true });
166
173
  }
167
- return true;
168
- }
169
- function resolvePathPrefix(config, args) {
170
- if (!config?.pathPrefix)
171
- return "";
172
- if (typeof config.pathPrefix === "string")
173
- return config.pathPrefix;
174
- const keys = config.stripPrefixKeys ?? [];
175
- if (!hasStringKeys(args, keys)) {
176
- const missing = keys.find((key) => typeof args[key] !== "string");
177
- throw new Error(`Missing path prefix key: ${missing}`);
174
+ static is(error) {
175
+ return typeof error === "object" && error !== null && API_ERROR_BRAND in error;
178
176
  }
179
- return config.pathPrefix(args);
180
177
  }
181
- function escapeRegex(value) {
182
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
178
+ function refuseLocally(path, message) {
179
+ return new ApiError("VALIDATION_ERROR", 0, { issues: [{ path, code: "invalid_type", message }] }, message);
183
180
  }
184
- function decodePathSegment(value) {
185
- try {
186
- return decodeURIComponent(value);
187
- } catch {
188
- return value;
181
+ function shouldRetryBunNetworkError(error) {
182
+ if (ApiError.is(error) || isHTTPError(error) || isNetworkError(error) || isTimeoutError(error) || error instanceof RequestCancellationError || error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
183
+ return;
189
184
  }
185
+ if (!(error instanceof Error))
186
+ return;
187
+ const code = Object.getOwnPropertyDescriptor(error, "code");
188
+ return code && "value" in code && code.value === "ConnectionRefused" ? true : undefined;
190
189
  }
191
- function createClientRouteMatcher(endpoint, contractPrefix, config) {
192
- if (typeof config?.pathPrefix === "function" && (!config.stripPrefixKeys || config.stripPrefixKeys.length === 0)) {
193
- throw new Error("Dynamic pathPrefix matchers require stripPrefixKeys");
194
- }
195
- const markerByKey = {};
196
- for (const [index, key] of (config?.stripPrefixKeys ?? []).entries()) {
197
- markerByKey[key] = `__stitch_scope_${index}__`;
198
- }
199
- const pathPrefix = resolvePathPrefix(config, markerByKey);
200
- const route = [pathPrefix, contractPrefix, endpoint.path === "/" ? "" : endpoint.path].filter(Boolean).join("/");
201
- const patternSegments = route.split("/").filter(Boolean);
202
- const wildcard = patternSegments.at(-1)?.startsWith("*") === true;
203
- const fixedCount = wildcard ? patternSegments.length - 1 : patternSegments.length;
204
- const markers = Object.values(markerByKey);
205
- return (pathname) => {
206
- const actualSegments = pathname.split("/").filter(Boolean).map(decodePathSegment);
207
- if (wildcard ? actualSegments.length < fixedCount : actualSegments.length !== fixedCount) {
208
- return false;
190
+ function parseApiErrorBody(body) {
191
+ if (!isRecord(body) || !isRecord(body.error))
192
+ return null;
193
+ const error = body.error;
194
+ if (typeof error.code !== "string")
195
+ return null;
196
+ return {
197
+ code: error.code,
198
+ message: typeof error.message === "string" ? error.message : undefined,
199
+ details: error.details,
200
+ hint: typeof error.hint === "string" ? error.hint : undefined
201
+ };
202
+ }
203
+ function createRetryAwareFetch(transportFetch, unix) {
204
+ const runtimeFetch = transportFetch;
205
+ let attempt = 0;
206
+ return (input, init) => {
207
+ attempt += 1;
208
+ if (unix === undefined && attempt === 1) {
209
+ return runtimeFetch(input, init);
209
210
  }
210
- for (let index = 0;index < fixedCount; index += 1) {
211
- const pattern = patternSegments[index];
212
- const actual = actualSegments[index];
213
- if (!pattern || actual === undefined)
214
- return false;
215
- if (pattern.startsWith(":"))
216
- continue;
217
- let source = escapeRegex(pattern);
218
- for (const marker of markers) {
219
- source = source.replaceAll(escapeRegex(marker), "[^/]+");
220
- }
221
- if (!new RegExp(`^${source}$`).test(actual))
222
- return false;
211
+ if (!(input instanceof Request)) {
212
+ if (unix === undefined)
213
+ return runtimeFetch(input, init);
214
+ const unixInit = { ...init, unix };
215
+ return runtimeFetch(input, unixInit);
223
216
  }
224
- return true;
217
+ const streamedBody = input.body ? { body: input.body, duplex: "half" } : {};
218
+ const materialized = {
219
+ ...init,
220
+ ...unix !== undefined && { unix },
221
+ method: input.method,
222
+ headers: input.headers,
223
+ ...streamedBody,
224
+ cache: input.cache,
225
+ credentials: input.credentials,
226
+ integrity: input.integrity,
227
+ keepalive: input.keepalive,
228
+ mode: input.mode,
229
+ redirect: input.redirect,
230
+ referrer: input.referrer,
231
+ referrerPolicy: input.referrerPolicy,
232
+ signal: input.signal
233
+ };
234
+ return runtimeFetch(input.url, materialized);
225
235
  };
226
236
  }
227
- function extractParamNames(path) {
228
- const matches = path.match(/:(\w+)/g);
229
- const names = matches ? matches.map((match) => match.slice(1)) : [];
230
- const wildcard = parseTrailingWildcard(path);
231
- if (wildcard)
232
- names.push(wildcard.name);
233
- return names;
234
- }
235
- function fillPathParams(path, args) {
236
- const wildcard = parseTrailingWildcard(path);
237
- let filled = path.replace(/:(\w+)/g, (_, key) => {
238
- const value = args[key];
239
- if (value === undefined || value === null) {
240
- throw new Error(`Missing path param: ${key}`);
241
- }
242
- return encodeURIComponent(String(value));
243
- });
244
- if (!wildcard)
245
- return filled;
246
- const wildcardValue = args[wildcard.name];
247
- if (wildcardValue === undefined || wildcardValue === null) {
248
- throw new Error(`Missing path param: ${wildcard.name}`);
249
- }
250
- const remainder = String(wildcardValue).split("/").map((segment) => encodeURIComponent(segment)).join("/");
251
- filled = `${filled.slice(0, -(wildcard.name.length + 1))}${remainder}`;
252
- return filled;
253
- }
254
- function stripConsumedArgs(args, path, scopeKeys) {
255
- const consumed = new Set(scopeKeys);
256
- for (const name of extractParamNames(path))
257
- consumed.add(name);
258
- const remaining = {};
259
- for (const [key, value] of Object.entries(args)) {
260
- if (!consumed.has(key) && value !== undefined)
261
- remaining[key] = value;
237
+ function createHttpClient(config) {
238
+ if (config.fetch && config.unix) {
239
+ throw new TypeError("HttpClientConfig.fetch and unix are mutually exclusive");
262
240
  }
263
- return remaining;
264
- }
265
- function appendQuery(relativeUrl, params) {
266
- if (!params)
267
- return relativeUrl;
268
- const search = new URLSearchParams;
269
- for (const [key, value] of Object.entries(params)) {
270
- if (Array.isArray(value)) {
271
- for (const item of value)
272
- search.append(key, String(item));
273
- } else {
274
- search.set(key, String(value));
241
+ if (config.unix !== undefined) {
242
+ if (!config.unix.startsWith("/") || config.unix.includes("\x00")) {
243
+ throw new TypeError("HttpClientConfig.unix must be an absolute Unix socket path");
244
+ }
245
+ if (typeof Reflect.get(globalThis, "Bun") !== "object") {
246
+ throw new TypeError("HttpClientConfig.unix requires Bun; on Bun or Node use createUnixClientTransport().fetch for an explicit portable transport");
275
247
  }
276
248
  }
277
- return search.size > 0 ? `${relativeUrl}?${search}` : relativeUrl;
278
- }
279
- function planClientRequest(endpoint, contractPrefix, args, config) {
280
- let pathPrefix = resolvePathPrefix(config, args);
281
- if (pathPrefix && !pathPrefix.endsWith("/"))
282
- pathPrefix += "/";
283
- if (pathPrefix.startsWith("/"))
284
- pathPrefix = pathPrefix.slice(1);
285
- const endpointPath = endpoint.path === "/" ? "" : endpoint.path;
286
- let relativeUrl = fillPathParams(`${pathPrefix}${contractPrefix}${endpointPath}`, args);
287
- if (relativeUrl.endsWith("/"))
288
- relativeUrl = relativeUrl.slice(0, -1);
289
- const remainingArgs = stripConsumedArgs(args, endpoint.path, config?.stripPrefixKeys ?? []);
290
- if (inputIsQuery(endpoint.method)) {
291
- relativeUrl = appendQuery(relativeUrl, collectQueryParams(remainingArgs, endpoint));
292
- }
293
- return { relativeUrl, remainingArgs };
294
- }
295
- function joinClientBaseUrl(baseUrl, relativeUrl) {
296
- const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
297
- const path = relativeUrl.startsWith("/") ? relativeUrl : `/${relativeUrl}`;
298
- return `${base}${path}`;
299
- }
300
-
301
- // src/internal/bounded-lines.ts
302
- var DEFAULT_STREAM_LINE_BYTES = 1024 * 1024;
303
- function lineLimit(value) {
304
- const resolved = value ?? DEFAULT_STREAM_LINE_BYTES;
305
- if (!Number.isSafeInteger(resolved) || resolved <= 0) {
306
- throw new TypeError("maxLineBytes must be a positive safe integer");
249
+ let ssrCookies = null;
250
+ let isLoggedOut = false;
251
+ const listeners = new Set;
252
+ const suppressUnauthorizedFor = config.suppressUnauthorizedFor ?? [];
253
+ const parseError = config.parseError ?? parseApiErrorBody;
254
+ function emit(event) {
255
+ for (const fn of listeners) {
256
+ try {
257
+ fn(event);
258
+ } catch {}
259
+ }
307
260
  }
308
- return resolved;
309
- }
310
- function joinBytes(left, right) {
311
- if (left.byteLength === 0)
312
- return right.slice();
313
- const result = new Uint8Array(left.byteLength + right.byteLength);
314
- result.set(left);
315
- result.set(right, left.byteLength);
316
- return result;
317
- }
318
- async function* readBoundedUtf8Lines(response, maxLineBytes, requireFinalNewline = false) {
319
- const reader = response.body?.getReader();
320
- if (!reader)
321
- return;
322
- const limit = lineLimit(maxLineBytes);
323
- const decoder = new TextDecoder("utf-8", { fatal: true });
324
- let pending = new Uint8Array;
325
- try {
326
- for (;; ) {
327
- const chunk = await reader.read();
328
- if (chunk.done)
329
- break;
330
- let start = 0;
331
- for (let index = 0;index < chunk.value.byteLength; index += 1) {
332
- if (chunk.value[index] !== 10)
261
+ const client = ky.create({
262
+ prefix: config.baseUrl,
263
+ credentials: config.credentials ?? "include",
264
+ timeout: false,
265
+ retry: {
266
+ limit: config.retry?.limit ?? 2,
267
+ methods: config.retry?.methods ?? ["get"],
268
+ statusCodes: config.retry?.statusCodes ?? [],
269
+ shouldRetry: ({ error }) => shouldRetryBunNetworkError(error)
270
+ },
271
+ hooks: {
272
+ beforeRequest: [
273
+ ({ request: request2 }) => {
274
+ if (ssrCookies) {
275
+ request2.headers.set("Cookie", ssrCookies);
276
+ }
277
+ const extra = typeof config.headers === "function" ? config.headers() : config.headers;
278
+ if (extra) {
279
+ for (const [key, value] of Object.entries(extra)) {
280
+ request2.headers.set(key, value);
281
+ }
282
+ }
283
+ if (config.trace && !request2.headers.has("traceparent")) {
284
+ request2.headers.set("traceparent", formatTraceparent(createTraceContext()));
285
+ }
286
+ }
287
+ ],
288
+ afterResponse: [
289
+ async ({ request: request2, response }) => {
290
+ if (response.status === 401) {
291
+ const url = new URL(request2.url).pathname;
292
+ if (!isLoggedOut && !suppressUnauthorizedFor.some((matches) => matches(url))) {
293
+ isLoggedOut = true;
294
+ emit({ type: "unauthorized" });
295
+ }
296
+ }
297
+ if (!response.ok) {
298
+ const body = await response.clone().json().catch(() => null);
299
+ if (body) {
300
+ const parsed = parseError(body);
301
+ if (parsed) {
302
+ throw new ApiError(parsed.code, response.status, parsed.details, parsed.message, parsed.hint, responseTraceId(response));
303
+ }
304
+ }
305
+ }
306
+ }
307
+ ]
308
+ }
309
+ });
310
+ async function request(method, url, data, options = {}) {
311
+ const cancellation = createRequestCancellation(options.signal, options.timeout ?? config.timeout ?? 30000);
312
+ const kyOptions = {
313
+ fetch: createRetryAwareFetch(config.fetch ?? globalThis.fetch.bind(globalThis), config.unix),
314
+ timeout: false,
315
+ signal: cancellation.signal
316
+ };
317
+ if (options.params) {
318
+ const searchParams = new URLSearchParams;
319
+ for (const [key, value] of Object.entries(options.params)) {
320
+ if (value === undefined)
333
321
  continue;
334
- const lineBytes = joinBytes(pending, chunk.value.slice(start, index));
335
- if (lineBytes.byteLength > limit) {
336
- throw new RangeError(`Stream line exceeds the ${limit} byte limit`);
322
+ if (Array.isArray(value)) {
323
+ for (const item of value)
324
+ searchParams.append(key, String(item));
325
+ } else {
326
+ searchParams.set(key, String(value));
337
327
  }
338
- yield decoder.decode(lineBytes);
339
- pending = new Uint8Array;
340
- start = index + 1;
341
328
  }
342
- pending = joinBytes(pending, chunk.value.slice(start));
343
- if (pending.byteLength > limit) {
344
- throw new RangeError(`Stream line exceeds the ${limit} byte limit`);
329
+ if (searchParams.size > 0) {
330
+ kyOptions.searchParams = searchParams;
345
331
  }
346
332
  }
347
- if (pending.byteLength > 0) {
348
- if (requireFinalNewline) {
349
- throw new SyntaxError("Stream ended with an unterminated final line");
333
+ if (data instanceof FormData) {
334
+ kyOptions.body = data;
335
+ } else if (data !== undefined) {
336
+ kyOptions.json = data;
337
+ }
338
+ try {
339
+ return await cancellation.run(async () => {
340
+ if (options.responseType === "blob") {
341
+ return transportResult(await client[method](url, kyOptions).blob());
342
+ }
343
+ if (options.responseType === "response") {
344
+ return transportResult(await client[method](url, kyOptions));
345
+ }
346
+ const response = await client[method](url, kyOptions);
347
+ if (options.responseType === "void") {
348
+ const text = await response.text();
349
+ if (text.length > 0) {
350
+ throw new Error("Server returned data for an endpoint with no output contract");
351
+ }
352
+ }
353
+ if (options.responseType === "void" || response.status === 204 || response.headers.get("content-length") === "0") {
354
+ return transportResult(undefined);
355
+ }
356
+ return await response.json();
357
+ });
358
+ } catch (error) {
359
+ if (error instanceof RequestCancellationError) {
360
+ throw new ApiError(error.cause === "caller" ? "REQUEST_ABORTED" : "REQUEST_TIMEOUT", 0, undefined, error.message);
350
361
  }
351
- yield decoder.decode(pending);
362
+ if (ApiError.is(error))
363
+ throw error;
364
+ emit({ type: "network_error" });
365
+ const response = isHTTPError(error) ? error.response : undefined;
366
+ const status = response?.status ?? 0;
367
+ const msg = error instanceof Error ? error.message : undefined;
368
+ throw new ApiError("UNKNOWN_ERROR", status, msg ? { message: msg } : undefined, msg, undefined, responseTraceId(response), { cause: error });
352
369
  }
353
- } finally {
354
- await reader.cancel().catch(() => {
355
- return;
356
- });
357
- reader.releaseLock();
358
370
  }
371
+ return {
372
+ baseUrl: config.baseUrl,
373
+ get: (url, options) => request("get", url, undefined, options),
374
+ head: (url, options) => request("head", url, undefined, options),
375
+ post: (url, data, options) => request("post", url, data, options),
376
+ put: (url, data, options) => request("put", url, data, options),
377
+ patch: (url, data, options) => request("patch", url, data, options),
378
+ delete: (url, options) => request("delete", url, undefined, options),
379
+ setServerContext(cookies) {
380
+ ssrCookies = cookies;
381
+ },
382
+ subscribe(listener) {
383
+ listeners.add(listener);
384
+ return () => listeners.delete(listener);
385
+ },
386
+ logout() {
387
+ isLoggedOut = true;
388
+ emit({ type: "logout" });
389
+ },
390
+ resetLogoutState() {
391
+ isLoggedOut = false;
392
+ }
393
+ };
359
394
  }
360
395
 
361
- // src/internal/errors.ts
362
- import { z } from "zod";
363
- function issuePath(path) {
364
- return path.length > 0 ? path.map(String).join(".") : "(root)";
396
+ // src/browser/client-multipart.ts
397
+ function isFileDescriptor(value) {
398
+ return typeof value === "object" && value !== null && !(value instanceof Blob) && "uri" in value && typeof value.uri === "string" && "name" in value && typeof value.name === "string" && "type" in value && typeof value.type === "string";
365
399
  }
366
- function zodIssues(error) {
367
- return error.issues.map((issue) => ({
368
- path: issuePath(issue.path),
369
- code: issue.code,
370
- message: issue.message
371
- }));
400
+ function isMultipartFile(value) {
401
+ return value instanceof Blob || isFileDescriptor(value);
372
402
  }
373
-
374
- // src/server/stream.ts
375
- async function* parseSSE(response, options) {
376
- try {
377
- for await (const rawLine of readBoundedUtf8Lines(response, options?.maxLineBytes ?? DEFAULT_STREAM_LINE_BYTES)) {
378
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
379
- if (!line.startsWith("data:"))
380
- continue;
381
- const data = line.slice(5).replace(/^ /, "");
382
- if (data === "[DONE]")
383
- return;
384
- try {
385
- yield JSON.parse(data);
386
- } catch (error) {
387
- const failure = error instanceof Error ? error : new Error(String(error));
388
- if (!options?.onParseError)
389
- throw failure;
390
- options.onParseError(data, failure);
403
+ function appendMultipartFile(form, field, file) {
404
+ const sink = form;
405
+ sink.append(field, file);
406
+ }
407
+ function appendFormFields(formData, values, skipKeys) {
408
+ for (const [key, value] of Object.entries(values)) {
409
+ if (skipKeys.has(key) || value === undefined || value === null)
410
+ continue;
411
+ formData.append(key, typeof value === "string" ? value : JSON.stringify(value));
412
+ }
413
+ }
414
+ function buildMultipartForm(descriptor, values) {
415
+ const formData = new FormData;
416
+ const fileFields = new Set(Object.keys(descriptor.files));
417
+ for (const [field, policy] of Object.entries(descriptor.files)) {
418
+ const value = values[field];
419
+ if (value === undefined) {
420
+ if (policy.required !== false) {
421
+ throw refuseLocally(field, `Missing multipart file field: ${field}`);
391
422
  }
423
+ continue;
392
424
  }
393
- } catch (error) {
394
- const failure = error instanceof Error ? error : new Error(String(error));
395
- if (!options?.onParseError)
396
- throw failure;
397
- options.onParseError("", failure);
425
+ if (policy.multiple === true) {
426
+ if (!Array.isArray(value) || value.length === 0) {
427
+ throw refuseLocally(field, `Multipart file field "${field}" must be a non-empty array`);
428
+ }
429
+ for (const file of value) {
430
+ if (!isMultipartFile(file)) {
431
+ throw refuseLocally(field, `Invalid multipart file field: ${field}`);
432
+ }
433
+ appendMultipartFile(formData, field, file);
434
+ }
435
+ continue;
436
+ }
437
+ if (!isMultipartFile(value)) {
438
+ throw refuseLocally(field, `Invalid multipart file field: ${field}`);
439
+ }
440
+ appendMultipartFile(formData, field, value);
398
441
  }
442
+ appendFormFields(formData, values, fileFields);
443
+ return formData;
399
444
  }
400
445
 
401
- // src/browser/http.ts
402
- import ky, {
403
- isHTTPError,
404
- isNetworkError,
405
- isTimeoutError
406
- } from "ky";
407
-
408
- // src/internal/random-hex.ts
409
- function randomHex(bytes) {
410
- const arr = new Uint8Array(bytes);
411
- crypto.getRandomValues(arr);
412
- let hex = "";
413
- for (const byte of arr)
414
- hex += byte.toString(16).padStart(2, "0");
415
- return hex;
446
+ // src/internal/http-input.ts
447
+ function inputIsQuery(method) {
448
+ return method === "GET" || method === "DELETE";
416
449
  }
417
450
 
418
- // src/observability/trace.ts
419
- var TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(.*)$/i;
420
- function createTraceContext() {
421
- return { traceId: randomHex(16), spanId: randomHex(8) };
451
+ // src/browser/client-url.ts
452
+ function isParamArray(value) {
453
+ return Array.isArray(value) && value.every((item) => typeof item === "string" || typeof item === "number");
422
454
  }
423
- function parseTraceparent(header) {
424
- if (!header)
425
- return null;
426
- const match = TRACEPARENT_RE.exec(header.trim());
427
- if (!match?.[1] || !match[2] || !match[3] || !match[4] || match[5] === undefined)
428
- return null;
429
- const version = match[1].toLowerCase();
430
- if (version === "ff")
431
- return null;
432
- const suffix = match[5];
433
- if (version === "00" ? suffix !== "" : suffix !== "" && !/^(?:-[0-9a-f]{2,})+$/i.test(suffix)) {
434
- return null;
455
+ function collectQueryParams(args, endpoint) {
456
+ const params = {};
457
+ let hasParams = false;
458
+ for (const [key, value] of Object.entries(args)) {
459
+ if (value === undefined || value === null)
460
+ continue;
461
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || isParamArray(value)) {
462
+ params[key] = value;
463
+ hasParams = true;
464
+ continue;
465
+ }
466
+ const what = Array.isArray(value) ? "an array with non-primitive items" : typeof value === "object" ? "a nested object" : `a ${typeof value}`;
467
+ throw refuseLocally(key, `${endpoint.method} ${endpoint.path}: input field "${key}" is ${what} — it cannot ` + "travel as a query parameter. GET / DELETE input must be flat (string / number / " + "boolean, or an array of string / number); flatten the field or move the " + "operation to a body verb (POST).");
435
468
  }
436
- const traceId = match[2].toLowerCase();
437
- const parentSpanId = match[3].toLowerCase();
438
- const traceFlags = match[4].toLowerCase();
439
- if (/^0+$/.test(traceId) || /^0+$/.test(parentSpanId))
440
- return null;
441
- return { traceId, spanId: randomHex(8), parentSpanId, traceFlags };
442
- }
443
- function formatTraceparent(ctx) {
444
- return `00-${ctx.traceId}-${ctx.spanId}-${ctx.traceFlags ?? "01"}`;
469
+ return hasParams ? params : undefined;
445
470
  }
446
- function childSpan(parent) {
447
- return {
448
- traceId: parent.traceId,
449
- spanId: randomHex(8),
450
- parentSpanId: parent.spanId,
451
- ...parent.tracestate !== undefined && { tracestate: parent.tracestate },
452
- ...parent.baggage !== undefined && { baggage: parent.baggage },
453
- ...parent.traceFlags !== undefined && { traceFlags: parent.traceFlags }
454
- };
471
+ function hasStringKeys(args, keys) {
472
+ for (const key of keys) {
473
+ if (typeof args[key] !== "string")
474
+ return false;
475
+ }
476
+ return true;
455
477
  }
456
- var encoder = new TextEncoder;
457
-
458
- // src/browser/request-id.ts
459
- var REQUEST_ID_HEADER = "x-request-id";
460
- function responseTraceId(response) {
461
- return response?.headers.get(REQUEST_ID_HEADER) ?? undefined;
478
+ function resolvePathPrefix(config, args) {
479
+ if (!config?.pathPrefix)
480
+ return "";
481
+ if (typeof config.pathPrefix === "string")
482
+ return config.pathPrefix;
483
+ const keys = config.stripPrefixKeys ?? [];
484
+ if (!hasStringKeys(args, keys)) {
485
+ const missing = keys.find((key) => typeof args[key] !== "string");
486
+ throw refuseLocally(String(missing), `Missing path prefix key: ${missing}`);
487
+ }
488
+ return config.pathPrefix(args);
462
489
  }
463
-
464
- // src/browser/http.ts
465
- var API_ERROR_BRAND = Symbol.for("stitchkit.ApiError");
466
- function messageForCode(code, message) {
467
- return message !== undefined && message.length > 0 ? message : `${code} (no message supplied)`;
490
+ function escapeRegex(value) {
491
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
468
492
  }
469
-
470
- class ApiError extends Error {
471
- code;
472
- status;
473
- details;
474
- hint;
475
- traceId;
476
- constructor(code, status = 0, details, message, hint, traceId, options) {
477
- super(messageForCode(code, message), options);
478
- this.code = code;
479
- this.status = status;
480
- this.details = details;
481
- this.hint = hint;
482
- this.traceId = traceId;
483
- this.name = "ApiError";
484
- Object.defineProperty(this, API_ERROR_BRAND, { value: true });
485
- }
486
- static is(error) {
487
- return typeof error === "object" && error !== null && API_ERROR_BRAND in error;
493
+ function decodePathSegment(value) {
494
+ try {
495
+ return decodeURIComponent(value);
496
+ } catch {
497
+ return value;
488
498
  }
489
499
  }
490
- function shouldRetryBunNetworkError(error) {
491
- if (ApiError.is(error) || isHTTPError(error) || isNetworkError(error) || isTimeoutError(error) || error instanceof RequestCancellationError || error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
492
- return;
500
+ function createClientRouteMatcher(endpoint, contractPrefix, config) {
501
+ if (typeof config?.pathPrefix === "function" && (!config.stripPrefixKeys || config.stripPrefixKeys.length === 0)) {
502
+ throw new Error("Dynamic pathPrefix matchers require stripPrefixKeys");
493
503
  }
494
- if (!(error instanceof Error))
495
- return;
496
- const code = Object.getOwnPropertyDescriptor(error, "code");
497
- return code && "value" in code && code.value === "ConnectionRefused" ? true : undefined;
498
- }
499
- function parseApiErrorBody(body) {
500
- if (!isRecord(body) || !isRecord(body.error))
501
- return null;
502
- const error = body.error;
503
- if (typeof error.code !== "string")
504
- return null;
505
- return {
506
- code: error.code,
507
- message: typeof error.message === "string" ? error.message : undefined,
508
- details: error.details,
509
- hint: typeof error.hint === "string" ? error.hint : undefined
510
- };
511
- }
512
- function createRetryAwareFetch(transportFetch, unix) {
513
- const runtimeFetch = transportFetch;
514
- let attempt = 0;
515
- return (input, init) => {
516
- attempt += 1;
517
- if (unix === undefined && attempt === 1) {
518
- return runtimeFetch(input, init);
504
+ const markerByKey = {};
505
+ for (const [index, key] of (config?.stripPrefixKeys ?? []).entries()) {
506
+ markerByKey[key] = `__stitch_scope_${index}__`;
507
+ }
508
+ const pathPrefix = resolvePathPrefix(config, markerByKey);
509
+ const route = [pathPrefix, contractPrefix, endpoint.path === "/" ? "" : endpoint.path].filter(Boolean).join("/");
510
+ const patternSegments = route.split("/").filter(Boolean);
511
+ const wildcard = patternSegments.at(-1)?.startsWith("*") === true;
512
+ const fixedCount = wildcard ? patternSegments.length - 1 : patternSegments.length;
513
+ const markers = Object.values(markerByKey);
514
+ return (pathname) => {
515
+ const actualSegments = pathname.split("/").filter(Boolean).map(decodePathSegment);
516
+ if (wildcard ? actualSegments.length < fixedCount : actualSegments.length !== fixedCount) {
517
+ return false;
519
518
  }
520
- if (!(input instanceof Request)) {
521
- if (unix === undefined)
522
- return runtimeFetch(input, init);
523
- const unixInit = { ...init, unix };
524
- return runtimeFetch(input, unixInit);
519
+ for (let index = 0;index < fixedCount; index += 1) {
520
+ const pattern = patternSegments[index];
521
+ const actual = actualSegments[index];
522
+ if (!pattern || actual === undefined)
523
+ return false;
524
+ if (pattern.startsWith(":"))
525
+ continue;
526
+ let source = escapeRegex(pattern);
527
+ for (const marker of markers) {
528
+ source = source.replaceAll(escapeRegex(marker), "[^/]+");
529
+ }
530
+ if (!new RegExp(`^${source}$`).test(actual))
531
+ return false;
525
532
  }
526
- const streamedBody = input.body ? { body: input.body, duplex: "half" } : {};
527
- const materialized = {
528
- ...init,
529
- ...unix !== undefined && { unix },
530
- method: input.method,
531
- headers: input.headers,
532
- ...streamedBody,
533
- cache: input.cache,
534
- credentials: input.credentials,
535
- integrity: input.integrity,
536
- keepalive: input.keepalive,
537
- mode: input.mode,
538
- redirect: input.redirect,
539
- referrer: input.referrer,
540
- referrerPolicy: input.referrerPolicy,
541
- signal: input.signal
542
- };
543
- return runtimeFetch(input.url, materialized);
533
+ return true;
544
534
  };
545
535
  }
546
- function createHttpClient(config) {
547
- if (config.fetch && config.unix) {
548
- throw new TypeError("HttpClientConfig.fetch and unix are mutually exclusive");
549
- }
550
- if (config.unix !== undefined) {
551
- if (!config.unix.startsWith("/") || config.unix.includes("\x00")) {
552
- throw new TypeError("HttpClientConfig.unix must be an absolute Unix socket path");
536
+ function extractParamNames(path) {
537
+ const matches = path.match(/:(\w+)/g);
538
+ const names = matches ? matches.map((match) => match.slice(1)) : [];
539
+ const wildcard = parseTrailingWildcard(path);
540
+ if (wildcard)
541
+ names.push(wildcard.name);
542
+ return names;
543
+ }
544
+ function fillPathParams(path, args) {
545
+ const wildcard = parseTrailingWildcard(path);
546
+ let filled = path.replace(/:(\w+)/g, (_, key) => {
547
+ const value = args[key];
548
+ if (value === undefined || value === null) {
549
+ throw refuseLocally(String(key), `Missing path param: ${key}`);
553
550
  }
554
- if (typeof Reflect.get(globalThis, "Bun") !== "object") {
555
- throw new TypeError("HttpClientConfig.unix requires Bun; on Bun or Node use createUnixClientTransport().fetch for an explicit portable transport");
551
+ return encodeURIComponent(String(value));
552
+ });
553
+ if (!wildcard)
554
+ return filled;
555
+ const wildcardValue = args[wildcard.name];
556
+ if (wildcardValue === undefined || wildcardValue === null) {
557
+ throw refuseLocally(wildcard.name, `Missing path param: ${wildcard.name}`);
558
+ }
559
+ const remainder = String(wildcardValue).split("/").map((segment) => encodeURIComponent(segment)).join("/");
560
+ filled = `${filled.slice(0, -(wildcard.name.length + 1))}${remainder}`;
561
+ return filled;
562
+ }
563
+ function stripConsumedArgs(args, path, scopeKeys) {
564
+ const consumed = new Set(scopeKeys);
565
+ for (const name of extractParamNames(path))
566
+ consumed.add(name);
567
+ const remaining = {};
568
+ for (const [key, value] of Object.entries(args)) {
569
+ if (!consumed.has(key) && value !== undefined)
570
+ remaining[key] = value;
571
+ }
572
+ return remaining;
573
+ }
574
+ function appendQuery(relativeUrl, params) {
575
+ if (!params)
576
+ return relativeUrl;
577
+ const search = new URLSearchParams;
578
+ for (const [key, value] of Object.entries(params)) {
579
+ if (Array.isArray(value)) {
580
+ for (const item of value)
581
+ search.append(key, String(item));
582
+ } else {
583
+ search.set(key, String(value));
556
584
  }
557
585
  }
558
- let ssrCookies = null;
559
- let isLoggedOut = false;
560
- const listeners = new Set;
561
- const suppressUnauthorizedFor = config.suppressUnauthorizedFor ?? [];
562
- const parseError = config.parseError ?? parseApiErrorBody;
563
- function emit(event) {
564
- for (const fn of listeners) {
565
- try {
566
- fn(event);
567
- } catch {}
568
- }
586
+ return search.size > 0 ? `${relativeUrl}?${search}` : relativeUrl;
587
+ }
588
+ function planClientRequest(endpoint, contractPrefix, args, config) {
589
+ let pathPrefix = resolvePathPrefix(config, args);
590
+ if (pathPrefix && !pathPrefix.endsWith("/"))
591
+ pathPrefix += "/";
592
+ if (pathPrefix.startsWith("/"))
593
+ pathPrefix = pathPrefix.slice(1);
594
+ const endpointPath = endpoint.path === "/" ? "" : endpoint.path;
595
+ let relativeUrl = fillPathParams(`${pathPrefix}${contractPrefix}${endpointPath}`, args);
596
+ if (relativeUrl.endsWith("/"))
597
+ relativeUrl = relativeUrl.slice(0, -1);
598
+ const remainingArgs = stripConsumedArgs(args, endpoint.path, config?.stripPrefixKeys ?? []);
599
+ if (inputIsQuery(endpoint.method)) {
600
+ relativeUrl = appendQuery(relativeUrl, collectQueryParams(remainingArgs, endpoint));
569
601
  }
570
- const client = ky.create({
571
- prefix: config.baseUrl,
572
- credentials: config.credentials ?? "include",
573
- timeout: false,
574
- retry: {
575
- limit: config.retry?.limit ?? 2,
576
- methods: config.retry?.methods ?? ["get"],
577
- statusCodes: config.retry?.statusCodes ?? [],
578
- shouldRetry: ({ error }) => shouldRetryBunNetworkError(error)
579
- },
580
- hooks: {
581
- beforeRequest: [
582
- ({ request: request2 }) => {
583
- if (ssrCookies) {
584
- request2.headers.set("Cookie", ssrCookies);
585
- }
586
- const extra = typeof config.headers === "function" ? config.headers() : config.headers;
587
- if (extra) {
588
- for (const [key, value] of Object.entries(extra)) {
589
- request2.headers.set(key, value);
590
- }
591
- }
592
- if (config.trace && !request2.headers.has("traceparent")) {
593
- request2.headers.set("traceparent", formatTraceparent(createTraceContext()));
594
- }
595
- }
596
- ],
597
- afterResponse: [
598
- async ({ request: request2, response }) => {
599
- if (response.status === 401) {
600
- const url = new URL(request2.url).pathname;
601
- if (!isLoggedOut && !suppressUnauthorizedFor.some((matches) => matches(url))) {
602
- isLoggedOut = true;
603
- emit({ type: "unauthorized" });
604
- }
605
- }
606
- if (!response.ok) {
607
- const body = await response.clone().json().catch(() => null);
608
- if (body) {
609
- const parsed = parseError(body);
610
- if (parsed) {
611
- throw new ApiError(parsed.code, response.status, parsed.details, parsed.message, parsed.hint, responseTraceId(response));
612
- }
613
- }
614
- }
615
- }
616
- ]
617
- }
618
- });
619
- async function request(method, url, data, options = {}) {
620
- const cancellation = createRequestCancellation(options.signal, options.timeout ?? config.timeout ?? 30000);
621
- const kyOptions = {
622
- fetch: createRetryAwareFetch(config.fetch ?? globalThis.fetch.bind(globalThis), config.unix),
623
- timeout: false,
624
- signal: cancellation.signal
625
- };
626
- if (options.params) {
627
- const searchParams = new URLSearchParams;
628
- for (const [key, value] of Object.entries(options.params)) {
629
- if (value === undefined)
602
+ return { relativeUrl, remainingArgs };
603
+ }
604
+ function joinClientBaseUrl(baseUrl, relativeUrl) {
605
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
606
+ const path = relativeUrl.startsWith("/") ? relativeUrl : `/${relativeUrl}`;
607
+ return `${base}${path}`;
608
+ }
609
+
610
+ // src/internal/bounded-lines.ts
611
+ var DEFAULT_STREAM_LINE_BYTES = 1024 * 1024;
612
+ function lineLimit(value) {
613
+ const resolved = value ?? DEFAULT_STREAM_LINE_BYTES;
614
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
615
+ throw new TypeError("maxLineBytes must be a positive safe integer");
616
+ }
617
+ return resolved;
618
+ }
619
+ function joinBytes(left, right) {
620
+ if (left.byteLength === 0)
621
+ return right.slice();
622
+ const result = new Uint8Array(left.byteLength + right.byteLength);
623
+ result.set(left);
624
+ result.set(right, left.byteLength);
625
+ return result;
626
+ }
627
+ async function* readBoundedUtf8Lines(response, maxLineBytes, requireFinalNewline = false) {
628
+ const reader = response.body?.getReader();
629
+ if (!reader)
630
+ return;
631
+ const limit = lineLimit(maxLineBytes);
632
+ const decoder = new TextDecoder("utf-8", { fatal: true });
633
+ let pending = new Uint8Array;
634
+ try {
635
+ for (;; ) {
636
+ const chunk = await reader.read();
637
+ if (chunk.done)
638
+ break;
639
+ let start = 0;
640
+ for (let index = 0;index < chunk.value.byteLength; index += 1) {
641
+ if (chunk.value[index] !== 10)
630
642
  continue;
631
- if (Array.isArray(value)) {
632
- for (const item of value)
633
- searchParams.append(key, String(item));
634
- } else {
635
- searchParams.set(key, String(value));
643
+ const lineBytes = joinBytes(pending, chunk.value.slice(start, index));
644
+ if (lineBytes.byteLength > limit) {
645
+ throw new RangeError(`Stream line exceeds the ${limit} byte limit`);
636
646
  }
647
+ yield decoder.decode(lineBytes);
648
+ pending = new Uint8Array;
649
+ start = index + 1;
637
650
  }
638
- if (searchParams.size > 0) {
639
- kyOptions.searchParams = searchParams;
651
+ pending = joinBytes(pending, chunk.value.slice(start));
652
+ if (pending.byteLength > limit) {
653
+ throw new RangeError(`Stream line exceeds the ${limit} byte limit`);
640
654
  }
641
655
  }
642
- if (data instanceof FormData) {
643
- kyOptions.body = data;
644
- } else if (data !== undefined) {
645
- kyOptions.json = data;
646
- }
647
- try {
648
- return await cancellation.run(async () => {
649
- if (options.responseType === "blob") {
650
- return transportResult(await client[method](url, kyOptions).blob());
651
- }
652
- if (options.responseType === "response") {
653
- return transportResult(await client[method](url, kyOptions));
654
- }
655
- const response = await client[method](url, kyOptions);
656
- if (options.responseType === "void") {
657
- const text = await response.text();
658
- if (text.length > 0) {
659
- throw new Error("Server returned data for an endpoint with no output contract");
660
- }
661
- }
662
- if (options.responseType === "void" || response.status === 204 || response.headers.get("content-length") === "0") {
663
- return transportResult(undefined);
664
- }
665
- return await response.json();
666
- });
667
- } catch (error) {
668
- if (error instanceof RequestCancellationError) {
669
- throw new ApiError(error.cause === "caller" ? "REQUEST_ABORTED" : "REQUEST_TIMEOUT", 0, undefined, error.message);
656
+ if (pending.byteLength > 0) {
657
+ if (requireFinalNewline) {
658
+ throw new SyntaxError("Stream ended with an unterminated final line");
670
659
  }
671
- if (ApiError.is(error))
672
- throw error;
673
- emit({ type: "network_error" });
674
- const response = isHTTPError(error) ? error.response : undefined;
675
- const status = response?.status ?? 0;
676
- const msg = error instanceof Error ? error.message : undefined;
677
- throw new ApiError("UNKNOWN_ERROR", status, msg ? { message: msg } : undefined, msg, undefined, responseTraceId(response), { cause: error });
660
+ yield decoder.decode(pending);
678
661
  }
662
+ } finally {
663
+ await reader.cancel().catch(() => {
664
+ return;
665
+ });
666
+ reader.releaseLock();
679
667
  }
680
- return {
681
- baseUrl: config.baseUrl,
682
- get: (url, options) => request("get", url, undefined, options),
683
- head: (url, options) => request("head", url, undefined, options),
684
- post: (url, data, options) => request("post", url, data, options),
685
- put: (url, data, options) => request("put", url, data, options),
686
- patch: (url, data, options) => request("patch", url, data, options),
687
- delete: (url, options) => request("delete", url, undefined, options),
688
- setServerContext(cookies) {
689
- ssrCookies = cookies;
690
- },
691
- subscribe(listener) {
692
- listeners.add(listener);
693
- return () => listeners.delete(listener);
694
- },
695
- logout() {
696
- isLoggedOut = true;
697
- emit({ type: "logout" });
698
- },
699
- resetLogoutState() {
700
- isLoggedOut = false;
668
+ }
669
+
670
+ // src/internal/errors.ts
671
+ import { z } from "zod";
672
+ function issuePath(path) {
673
+ return path.length > 0 ? path.map(String).join(".") : "(root)";
674
+ }
675
+ function formatZodError(error) {
676
+ const issues = error.issues.slice(0, 5);
677
+ const lines = issues.map((issue) => `${issuePath(issue.path)}: ${issue.message}`);
678
+ const suffix = error.issues.length > 5 ? `
679
+ ...and ${error.issues.length - 5} more issues` : "";
680
+ return lines.join(`
681
+ `) + suffix;
682
+ }
683
+ function zodIssues(error) {
684
+ return error.issues.map((issue) => ({
685
+ path: issuePath(issue.path),
686
+ code: issue.code,
687
+ message: issue.message
688
+ }));
689
+ }
690
+
691
+ // src/server/stream.ts
692
+ async function* parseSSE(response, options) {
693
+ try {
694
+ for await (const rawLine of readBoundedUtf8Lines(response, options?.maxLineBytes ?? DEFAULT_STREAM_LINE_BYTES)) {
695
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
696
+ if (!line.startsWith("data:"))
697
+ continue;
698
+ const data = line.slice(5).replace(/^ /, "");
699
+ if (data === "[DONE]")
700
+ return;
701
+ try {
702
+ yield JSON.parse(data);
703
+ } catch (error) {
704
+ const failure = error instanceof Error ? error : new Error(String(error));
705
+ if (!options?.onParseError)
706
+ throw failure;
707
+ options.onParseError(data, failure);
708
+ }
701
709
  }
702
- };
710
+ } catch (error) {
711
+ const failure = error instanceof Error ? error : new Error(String(error));
712
+ if (!options?.onParseError)
713
+ throw failure;
714
+ options.onParseError("", failure);
715
+ }
703
716
  }
704
717
  // src/browser/stream.ts
705
718
  function parseFailure(raw, error, onParseError) {
@@ -928,22 +941,29 @@ function setClientMethod(target, key, method) {
928
941
  function createEndpointMethod(endpoint, execute, contractConfig) {
929
942
  const hasScopedArguments = (contractConfig?.stripPrefixKeys?.length ?? 0) > 0;
930
943
  if (endpointHasArguments(endpoint) || hasScopedArguments) {
931
- const method2 = (requestArgs) => execute(readClientRequestArgs(requestArgs), undefined);
944
+ const method2 = (requestArgs) => settle(execute, readClientRequestArgs(requestArgs), undefined);
932
945
  return Object.assign(method2, {
933
946
  withOptions: (...args) => {
934
947
  refuseExtraWithOptionsArguments(endpoint, args.length, 2);
935
- return execute(readClientRequestArgs(args[0]), readClientRequestOptions(args[1]));
948
+ return settle(execute, readClientRequestArgs(args[0]), readClientRequestOptions(args[1]));
936
949
  }
937
950
  });
938
951
  }
939
- const method = () => execute({}, undefined);
952
+ const method = () => settle(execute, {}, undefined);
940
953
  return Object.assign(method, {
941
954
  withOptions: (...args) => {
942
955
  refuseExtraWithOptionsArguments(endpoint, args.length, 1);
943
- return execute({}, readClientRequestOptions(args[0]));
956
+ return settle(execute, {}, readClientRequestOptions(args[0]));
944
957
  }
945
958
  });
946
959
  }
960
+ function settle(execute, requestArgs, options) {
961
+ try {
962
+ return execute(requestArgs, options);
963
+ } catch (error) {
964
+ return Promise.reject(error);
965
+ }
966
+ }
947
967
  function refuseExtraWithOptionsArguments(endpoint, received, expected) {
948
968
  if (received <= expected)
949
969
  return;
@@ -2653,6 +2673,7 @@ var RealtimeRejectReasonSchema = z7.enum([
2653
2673
  ]);
2654
2674
  var RealtimeRejectFaultSchema = z7.enum(["peer", "local"]);
2655
2675
  export {
2676
+ zodIssues,
2656
2677
  unauthorized,
2657
2678
  resumableIterator,
2658
2679
  rateLimited,
@@ -2662,6 +2683,7 @@ export {
2662
2683
  paginatedSchema,
2663
2684
  notFound,
2664
2685
  isStitchErrorCode,
2686
+ formatZodError,
2665
2687
  formatTraceparent,
2666
2688
  forbidden,
2667
2689
  encodeCursor,