arthron 1.2.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as redactUrl, a as cancellationError, c as responseError, d as runtimeStreamsRequestBody, f as serializeBody, g as leavesBase, h as fillPath, i as logging$1, m as isArthronError, n as timeout$1, o as isCancellation, p as ArthronError, r as retry$1, s as networkError, u as isReadableStream, v as redactUrlIn, y as resolveUrl } from "./plugins-CHCMxtAE.mjs";
1
+ import { C as redactUrlIn, S as redactUrl, T as withoutSecrets, _ as httpOrigin, a as redactErrorUrl, d as runtimeStreamsRequestBody, f as serializeBody, g as fillPath, h as isArthronError, i as networkError, m as ArthronError, n as isCancellation, o as responseError, p as tagOf, t as cancellationError, u as isReadableStream, w as resolveUrl, x as originSet, y as leavesBase } from "./shared.mjs";
2
2
  //#region src/request/headers.ts
3
3
  const isPlainRecord = (headers) => !Array.isArray(headers) && Object.prototype.toString.call(headers) !== "[object Headers]";
4
4
  /**
@@ -23,22 +23,31 @@ function toHeaderRecord(headers) {
23
23
  }
24
24
  //#endregion
25
25
  //#region src/pipeline/pipeline.ts
26
- /** The mutable body kinds an interceptor can `append` to; anything else is shared as is. */
26
+ /**
27
+ * The mutable body kinds an interceptor can `append` to; anything else is
28
+ * shared as is. Known by type tag, as serializeBody knows them, and copied
29
+ * into their own class — a polyfill's (formdata-node) or another realm's
30
+ * holds values the global one may not take.
31
+ */
27
32
  function cloneBody(body) {
28
- if (typeof FormData !== "undefined" && body instanceof FormData) {
29
- const copy = new FormData();
33
+ const tag = tagOf(body);
34
+ if (tag !== "FormData" && tag !== "URLSearchParams") return body;
35
+ const Own = body.constructor;
36
+ try {
37
+ if (tag === "URLSearchParams") return new Own(String(body));
38
+ const copy = new Own();
30
39
  for (const [name, value] of body) copy.append(name, value);
31
40
  return copy;
41
+ } catch {
42
+ return body;
32
43
  }
33
- if (body instanceof URLSearchParams) return new URLSearchParams(body);
34
- return body;
35
44
  }
36
45
  /**
37
46
  * Interceptors mutate the context in place. A middleware that calls `next`
38
47
  * again (retry) must not see the previous attempt's changes, so every attempt
39
48
  * starts from a fresh copy of the headers and of a FormData/URLSearchParams body.
40
49
  */
41
- function cloneContext({ url, init, meta, fetch }) {
50
+ function cloneContext({ url, init, meta, fetchFn }) {
42
51
  const copy = {
43
52
  ...init,
44
53
  headers: toHeaderRecord(init.headers)
@@ -49,7 +58,7 @@ function cloneContext({ url, init, meta, fetch }) {
49
58
  init: copy,
50
59
  meta: { ...meta }
51
60
  };
52
- if (fetch) out.fetch = fetch;
61
+ if (fetchFn) out.fetchFn = fetchFn;
53
62
  return out;
54
63
  }
55
64
  /**
@@ -66,11 +75,20 @@ function normalize(ctx, previous) {
66
75
  },
67
76
  meta: ctx.meta ?? previous.meta
68
77
  };
69
- const fetch = ctx.fetch ?? previous.fetch;
70
- if (fetch) out.fetch = fetch;
78
+ const fetchFn = ctx.fetchFn ?? previous.fetchFn;
79
+ if (fetchFn) out.fetchFn = fetchFn;
71
80
  return out;
72
81
  }
73
82
  /**
83
+ * The 1.x name of `fetchFn`. TypeScript does not flag it on a spread
84
+ * (`{ ...ctx, fetch }`), and ignored, a test's mock would be passed over for
85
+ * the real network.
86
+ */
87
+ function refuseFetchField(ctx, problem, fix) {
88
+ if (ctx.fetch === void 0) return;
89
+ throw new TypeError(`arthron: ${problem}, which is requestContext.fetchFn since 2.0 — ${fix} { ...requestContext, fetchFn }`);
90
+ }
91
+ /**
74
92
  * A plugin written in JS (or with a cast) that forgets to return would hand
75
93
  * `undefined` on — and an error interceptor's would be what the caller
76
94
  * catches. Name the plugin slot instead.
@@ -80,7 +98,9 @@ function returned(value, who, what, cause, verb = "returned") {
80
98
  throw new TypeError(`arthron: ${who} ${verb} ${value} — return ${what}`, cause === void 0 ? void 0 : { cause });
81
99
  }
82
100
  /**
83
- * Owns the order of one request.
101
+ * Owns the order of every request a client sends. The plugins are sorted
102
+ * into their slots once, when the client is created — a client's plugins
103
+ * never change.
84
104
  *
85
105
  * Middleware wraps the whole call, outermost first in registration order.
86
106
  * Each attempt below it runs request interceptors → `send` → response
@@ -88,56 +108,61 @@ function returned(value, who, what, cause, verb = "returned") {
88
108
  * they return is thrown. Anything else that is thrown — cancellation, a bug
89
109
  * in a plugin — propagates untouched.
90
110
  */
91
- async function runPipeline(plugins, ctx, send) {
111
+ function createPipeline(plugins) {
92
112
  const middlewares = [];
93
- const request = [];
94
- const response = [];
95
- const error = [];
113
+ const requestInterceptors = [];
114
+ const responseInterceptors = [];
115
+ const errorInterceptors = [];
96
116
  for (const plugin of plugins) {
97
117
  if (plugin.middleware) middlewares.push(plugin.middleware);
98
- if (plugin.request) request.push(plugin.request);
99
- if (plugin.response) response.push(plugin.response);
100
- if (plugin.error) error.push(plugin.error);
118
+ if (plugin.requestInterceptor) requestInterceptors.push(plugin.requestInterceptor);
119
+ if (plugin.responseInterceptor) responseInterceptors.push(plugin.responseInterceptor);
120
+ if (plugin.errorInterceptor) errorInterceptors.push(plugin.errorInterceptor);
101
121
  }
102
- const attempt = async (input) => {
122
+ const attempt = async (input, send) => {
103
123
  let current = cloneContext(input);
104
- for (const interceptor of request) current = normalize(returned(await interceptor(current), "a request interceptor", "the context (ctx or a changed copy)"), current);
124
+ for (const interceptor of requestInterceptors) {
125
+ const changed = returned(await interceptor(current), "a request interceptor", "the context (requestContext or a changed copy)");
126
+ refuseFetchField(changed, "a request interceptor set requestContext.fetch", "return");
127
+ current = normalize(changed, current);
128
+ }
105
129
  let res;
106
130
  try {
107
131
  res = await send(current);
108
132
  } catch (thrown) {
109
133
  if (!isArthronError(thrown)) throw thrown;
110
134
  let err = thrown;
111
- for (const interceptor of error) err = returned(await interceptor(err, current), "an error interceptor", "the error (err or a changed one)", thrown);
135
+ for (const interceptor of errorInterceptors) err = returned(await interceptor(err, current), "an error interceptor", "the error (error or a changed one)", thrown);
112
136
  throw err;
113
137
  }
114
- for (const interceptor of response) res = returned(await interceptor(res, current), "a response interceptor", "the Response (res or a new one)");
138
+ for (const interceptor of responseInterceptors) res = returned(await interceptor(res, current), "a response interceptor", "the Response (response or a new one)");
115
139
  return res;
116
140
  };
117
- const dispatch = async (index, input) => {
141
+ const runFrom = async (index, input, send) => {
118
142
  const middleware = middlewares[index];
119
- if (!middleware) return attempt(input);
120
- return returned(await middleware(input, (next) => {
143
+ if (!middleware) return attempt(input, send);
144
+ return returned(await middleware(input, (passed) => {
145
+ refuseFetchField(passed, "a middleware passed requestContext.fetch to next", "pass");
121
146
  const ctx = {
122
- ...next,
123
- meta: next.meta ?? input.meta
147
+ ...passed,
148
+ meta: passed.meta ?? input.meta
124
149
  };
125
- const fetch = next.fetch ?? input.fetch;
126
- if (fetch) ctx.fetch = fetch;
127
- return dispatch(index + 1, ctx);
128
- }), "a middleware", "the Response, usually `return next(ctx)`", void 0, "resolved with");
150
+ const fetchFn = passed.fetchFn ?? input.fetchFn;
151
+ if (fetchFn) ctx.fetchFn = fetchFn;
152
+ return runFrom(index + 1, ctx, send);
153
+ }), "a middleware", "the Response, usually `return next(requestContext)`", void 0, "resolved with");
129
154
  };
130
- return dispatch(0, ctx);
155
+ return (ctx, send) => runFrom(0, ctx, send);
131
156
  }
132
157
  //#endregion
133
158
  //#region src/response/schema-validation.ts
134
159
  const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
135
160
  /**
136
- * `.json(schema)` got a body that does not match. Not an ArthronError: the
137
- * request succeeded — the data is what is wrong.
161
+ * `.json(schema)` got a body that does not match the schema. Not an
162
+ * ArthronError: the request succeeded — the data is what is wrong.
138
163
  */
139
- var SchemaError = class extends Error {
140
- name = "SchemaError";
164
+ var SchemaMismatchError = class extends Error {
165
+ name = "SchemaMismatchError";
141
166
  /** Every problem the validator reported, with the path to it. */
142
167
  issues;
143
168
  constructor(issues) {
@@ -148,15 +173,16 @@ var SchemaError = class extends Error {
148
173
  this.issues = issues;
149
174
  }
150
175
  };
151
- /** Validate `data`; resolve with the schema's output or reject with a SchemaError. */
152
- async function validate(schema, data) {
153
- const result = await schema["~standard"].validate(data);
154
- if (result.issues) throw new SchemaError(result.issues);
176
+ /** Validate `body`; resolve with the schema's output or reject with a SchemaMismatchError. */
177
+ async function validate(schema, body) {
178
+ const result = await schema["~standard"].validate(body);
179
+ if (result.issues) throw new SchemaMismatchError(result.issues);
155
180
  return result.value;
156
181
  }
157
182
  //#endregion
158
183
  //#region src/response/stream-parser.ts
159
184
  const DEFAULT_LIMIT = 8388608;
185
+ const DECODE_CHUNK_BYTES = 16384;
160
186
  /**
161
187
  * Yield the body line by line, reassembling lines split across chunks. Only
162
188
  * the newly arrived text is scanned, so a long line costs linear time; a line
@@ -173,28 +199,37 @@ async function* lines(response, maxLineLength) {
173
199
  const breaks = /\r\n|\r|\n/g;
174
200
  let pending = "";
175
201
  let afterCR = false;
202
+ const scan = function* (decoded) {
203
+ let text = decoded;
204
+ if (afterCR && text.startsWith("\n")) {
205
+ text = text.slice(1);
206
+ afterCR = false;
207
+ }
208
+ if (text) afterCR = text.endsWith("\r");
209
+ let start = 0;
210
+ breaks.lastIndex = 0;
211
+ for (let m = breaks.exec(text); m; m = breaks.exec(text)) {
212
+ yield checked(pending + text.slice(start, m.index));
213
+ pending = "";
214
+ start = breaks.lastIndex;
215
+ }
216
+ pending = checked(pending + text.slice(start));
217
+ };
176
218
  try {
177
219
  while (true) {
178
220
  const { value, done } = await reader.read();
179
- let text = decoder.decode(value, { stream: !done });
180
- if (afterCR && text.startsWith("\n")) {
181
- text = text.slice(1);
182
- afterCR = false;
221
+ if (value) for (let offset = 0; offset < value.byteLength; offset += DECODE_CHUNK_BYTES) {
222
+ const part = value.subarray(offset, offset + DECODE_CHUNK_BYTES);
223
+ yield* scan(decoder.decode(part, { stream: true }));
183
224
  }
184
- if (text) afterCR = text.endsWith("\r");
185
- let start = 0;
186
- breaks.lastIndex = 0;
187
- for (let m = breaks.exec(text); m; m = breaks.exec(text)) {
188
- yield checked(pending + text.slice(start, m.index));
189
- pending = "";
190
- start = breaks.lastIndex;
225
+ if (done) {
226
+ yield* scan(decoder.decode());
227
+ break;
191
228
  }
192
- pending = checked(pending + text.slice(start));
193
- if (done) break;
194
229
  }
195
230
  if (pending) yield pending;
196
231
  } finally {
197
- await reader.cancel().catch(() => {});
232
+ reader.cancel().catch(() => {});
198
233
  reader.releaseLock();
199
234
  }
200
235
  }
@@ -206,24 +241,24 @@ async function* lines(response, maxLineLength) {
206
241
  * Provider sentinels like
207
242
  * `[DONE]` are passed through untouched — they are not part of the spec.
208
243
  */
209
- async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxEventSize = DEFAULT_LIMIT } = {}) {
244
+ async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxDataLength = DEFAULT_LIMIT } = {}) {
210
245
  let data = [];
211
246
  let size = 0;
212
- let event;
247
+ let type;
213
248
  let id;
214
249
  let lastEventId = "";
215
- let retry;
250
+ let reconnectionTime;
216
251
  const flush = () => {
217
252
  const out = data.length === 0 ? void 0 : {
253
+ type: type || "message",
218
254
  data: data.join("\n"),
219
255
  lastEventId
220
256
  };
221
- if (out && event !== void 0) out.event = event;
222
257
  if (out && id !== void 0) out.id = id;
223
- if (out && retry !== void 0) out.retry = retry;
258
+ if (out && reconnectionTime !== void 0) out.reconnectionTime = reconnectionTime;
224
259
  data = [];
225
260
  size = 0;
226
- event = id = void 0;
261
+ type = id = void 0;
227
262
  return out;
228
263
  };
229
264
  for await (const line of lines(response, maxLineLength)) {
@@ -240,16 +275,16 @@ async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxEventSize
240
275
  switch (field) {
241
276
  case "data":
242
277
  size += value.length + 1;
243
- if (size > maxEventSize + 1) throw new RangeError(`arthron: an SSE event's data is longer than ${maxEventSize} characters — pass a larger maxEventSize to .sse() if that is expected`);
278
+ if (size > maxDataLength + 1) throw new RangeError(`arthron: an SSE event's data is longer than ${maxDataLength} characters — pass a larger maxDataLength to .sse() if that is expected`);
244
279
  data.push(value);
245
280
  break;
246
281
  case "event":
247
- event = value;
282
+ type = value;
248
283
  break;
249
284
  case "id":
250
285
  if (!value.includes("\0")) id = lastEventId = value;
251
286
  break;
252
- case "retry": if (/^\d+$/.test(value)) retry = Number(value);
287
+ case "retry": if (/^\d+$/.test(value)) reconnectionTime = Number(value);
253
288
  }
254
289
  }
255
290
  }
@@ -264,7 +299,7 @@ async function* parseNdjson(response, { maxLineLength = DEFAULT_LIMIT } = {}) {
264
299
  }
265
300
  }
266
301
  //#endregion
267
- //#region src/response/arthron-promise.ts
302
+ //#region src/response/response-promise.ts
268
303
  /** Defer the Response until iteration starts, so the helper itself never rejects. */
269
304
  function iterate(promise, parse, signal) {
270
305
  promise.catch(() => {});
@@ -280,14 +315,14 @@ function iterate(promise, parse, signal) {
280
315
  * Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
281
316
  * without a helper still yields the untouched `Response`.
282
317
  */
283
- function toArthronPromise(promise, signal) {
284
- const read = (body) => promise.then(body).catch((error) => {
318
+ function toResponsePromise(promise, signal) {
319
+ const read = (readAs) => promise.then(readAs).catch((error) => {
285
320
  throw cancellationError(error, signal);
286
321
  });
287
322
  return Object.assign(promise, {
288
323
  json: ((schema) => read(async (res) => {
289
- const data = await res.json();
290
- return schema ? validate(schema, data) : data;
324
+ const body = await res.json();
325
+ return schema ? validate(schema, body) : body;
291
326
  })),
292
327
  text: () => read((res) => res.text()),
293
328
  blob: () => read((res) => res.blob()),
@@ -298,19 +333,75 @@ function toArthronPromise(promise, signal) {
298
333
  });
299
334
  }
300
335
  //#endregion
301
- //#region src/client/resolve-config.ts
336
+ //#region src/pipeline/create-plugin.ts
337
+ /** The slots a plugin may fill. */
338
+ const SLOTS = [
339
+ "middleware",
340
+ "requestInterceptor",
341
+ "responseInterceptor",
342
+ "errorInterceptor"
343
+ ];
344
+ /** 1.x slot names: a plugin that still uses one would have it ignored. */
345
+ const REPLACED_SLOTS = {
346
+ request: "requestInterceptor",
347
+ response: "responseInterceptor",
348
+ error: "errorInterceptor"
349
+ };
350
+ /**
351
+ * Throws a TypeError for what a plugin can get wrong where TypeScript does
352
+ * not look — JS, a cast, an object built at runtime: not an object, no
353
+ * pluginName, a key it does not take (a typo, a 1.x name), a slot that is not
354
+ * a function, or nothing to run. `createPlugin` runs it where the plugin is
355
+ * written; `use` and `plugins` run it again on whatever they are given.
356
+ */
357
+ function checkPlugin(plugin) {
358
+ if (typeof plugin !== "object" || plugin === null) throw new TypeError(`arthron: a plugin is an object, got ${plugin === null ? "null" : `a ${typeof plugin}`} — e.g. createPlugin({ pluginName: "trace", middleware: fn })`);
359
+ const fields = plugin;
360
+ const own = (key) => Object.hasOwn(fields, key) ? fields[key] : void 0;
361
+ const given = fields.pluginName ?? own("name");
362
+ const label = typeof given === "string" && given !== "" ? `plugin "${given}"` : "a plugin";
363
+ if (own("name") !== void 0) throw new TypeError(`arthron: ${label}: the name field is called pluginName since 2.0 — rename it in the plugin`);
364
+ for (const [old, now] of Object.entries(REPLACED_SLOTS)) if (own(old) !== void 0) throw new TypeError(`arthron: ${label}: the ${old} interceptor is called ${now} since 2.0 — rename it in the plugin`);
365
+ if (typeof fields.pluginName !== "string" || fields.pluginName === "") throw new TypeError("arthron: a plugin needs a pluginName — e.g. createPlugin({ pluginName: \"trace\", … })");
366
+ for (const key of Object.keys(fields)) if (key !== "pluginName" && !SLOTS.includes(key)) throw new TypeError(`arthron: ${label} has an unknown key "${key}" — use pluginName, middleware, requestInterceptor, responseInterceptor or errorInterceptor; keep state in a closure, in a function that returns createPlugin({ … })`);
367
+ for (const slot of SLOTS) if (fields[slot] !== void 0 && typeof fields[slot] !== "function") throw new TypeError(`arthron: ${label}: ${slot} must be a function`);
368
+ if (SLOTS.every((slot) => fields[slot] === void 0)) throw new TypeError(`arthron: ${label} has nothing to run — add middleware, requestInterceptor, responseInterceptor or errorInterceptor`);
369
+ }
370
+ /**
371
+ * A plugin, checked where it is written and typed without annotations:
372
+ * `createPlugin({ pluginName: "trace", requestInterceptor: (requestContext) => … })`.
373
+ */
374
+ function createPlugin(plugin) {
375
+ checkPlugin(plugin);
376
+ return plugin;
377
+ }
378
+ function aFunction(value, helper, example) {
379
+ if (typeof value !== "function") throw new TypeError(`arthron: ${helper} needs a function — e.g. ${example}`);
380
+ return value;
381
+ }
382
+ /** A middleware written on its own, typed without annotations. */
383
+ const createMiddleware = (middleware) => aFunction(middleware, "createMiddleware", "createMiddleware((requestContext, next) => next(requestContext))");
384
+ /** A request interceptor written on its own, typed without annotations. */
385
+ const createRequestInterceptor = (interceptor) => aFunction(interceptor, "createRequestInterceptor", "createRequestInterceptor((requestContext) => requestContext)");
386
+ /** A response interceptor written on its own, typed without annotations. */
387
+ const createResponseInterceptor = (interceptor) => aFunction(interceptor, "createResponseInterceptor", "createResponseInterceptor((response) => response)");
388
+ /** An error interceptor written on its own, typed without annotations. */
389
+ const createErrorInterceptor = (interceptor) => aFunction(interceptor, "createErrorInterceptor", "createErrorInterceptor((error) => error)");
390
+ //#endregion
391
+ //#region src/client/resolve-options.ts
302
392
  /**
303
- * Config keys that configure arthron itself; every other key is a fetch
304
- * default. Typed so that a new config key fails to compile until it is
305
- * listed here — otherwise it would be sent to fetch.
393
+ * Option keys that configure arthron itself; every other key is a fetch
394
+ * default. Typed so that a new option key fails to compile until it is
395
+ * listed here — otherwise it would be sent to fetch. `headers` is a fetch
396
+ * field too, but one with arthron's own merge rules.
306
397
  */
307
398
  const OWN_KEYS = {
308
399
  baseURL: true,
309
- allowAbsoluteUrls: true,
400
+ allowedOrigins: true,
310
401
  headers: true,
311
- fetch: true,
312
- use: true,
313
- errorDetail: true,
402
+ fetchFn: true,
403
+ plugins: true,
404
+ extractDescription: true,
314
405
  throwOn: true
315
406
  };
316
407
  /** Fields that belong to one request, refused as client defaults. */
@@ -320,15 +411,27 @@ const PER_CALL_ONLY = [
320
411
  "method"
321
412
  ];
322
413
  /**
323
- * Add plugins to a list. A named plugin takes the place of the one already
324
- * there with that name — same position, so a replaced `logging()` stays
325
- * outermost and a second `retry()` does not multiply attempts. Unnamed
326
- * plugins append.
414
+ * 1.x config keys and what replaced them. Left alone, each would become an
415
+ * unknown key, then a fetch default, and be ignored: plugins not applied, a
416
+ * test's fake fetch passed over for the real network.
417
+ */
418
+ const REPLACED = {
419
+ fetch: "fetchFn",
420
+ use: "plugins",
421
+ errorDetail: "extractDescription",
422
+ allowAbsoluteUrls: "allowedOrigins"
423
+ };
424
+ /**
425
+ * Add plugins to a list, each checked as `createPlugin` checks it. A plugin
426
+ * takes the place of the one already there with its pluginName — same position,
427
+ * so a replaced `logging()` stays outermost and a second `retry()` does not
428
+ * multiply attempts. Other names append.
327
429
  */
328
430
  function addPlugins(base, added) {
329
431
  const out = [...base];
330
432
  for (const plugin of added) {
331
- const at = plugin.name === void 0 ? -1 : out.findIndex((p) => p.name === plugin.name);
433
+ checkPlugin(plugin);
434
+ const at = out.findIndex((p) => p.pluginName === plugin.pluginName);
332
435
  if (at === -1) out.push(plugin);
333
436
  else out[at] = plugin;
334
437
  }
@@ -339,31 +442,57 @@ function fetchDefaults(config) {
339
442
  return Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== void 0 && !(key in OWN_KEYS)));
340
443
  }
341
444
  /** Normalize once so `extend` can merge headers and add plugins cheaply. */
342
- function resolveConfig(config) {
445
+ function resolveClientOptions(config) {
343
446
  for (const key of PER_CALL_ONLY) if (config[key] !== void 0) throw new TypeError(`arthron: ${key} cannot be a client default, it belongs to one request — pass it per call (api.get(path, { signal }), api.post(path, body), api.request(path, { method }))`);
344
- const { headers, use, ...rest } = config;
345
- return {
447
+ for (const [old, now] of Object.entries(REPLACED)) if (config[old] !== void 0) throw new TypeError(`arthron: the ${old} option was replaced by ${now} in 2.0 — pass arthron({ ${now} })`);
448
+ const { headers, plugins, allowedOrigins, ...rest } = config;
449
+ if (rest.fetchFn !== void 0 && typeof rest.fetchFn !== "function") throw new TypeError(`arthron: fetchFn must be a function with fetch's signature, got ${kindOf(rest.fetchFn)} — e.g. arthron({ fetchFn: fetch })`);
450
+ if (rest.baseURL !== void 0) checkBaseURL(rest.baseURL);
451
+ if (allowedOrigins !== void 0 && !Array.isArray(allowedOrigins)) throw new TypeError(`arthron: allowedOrigins takes a list of URLs, got ${kindOf(allowedOrigins)} — e.g. allowedOrigins: ["https://cdn.example.com"]`);
452
+ const record = toHeaderRecord(headers);
453
+ if (rest.baseURL === void 0 && allowedOrigins !== void 0 && allowedOrigins.length > 0 && Object.keys(record).length > 0) throw new TypeError("arthron: config headers go to baseURL's origin only, and this client has allowedOrigins but no baseURL — set baseURL, or pass these headers per call");
454
+ const resolved = {
346
455
  ...rest,
347
- headers: toHeaderRecord(headers),
348
- use: addPlugins([], use ?? [])
456
+ headers: record,
457
+ plugins: addPlugins([], plugins ?? [])
349
458
  };
459
+ if (allowedOrigins !== void 0) resolved.allowedOrigins = [...allowedOrigins];
460
+ return resolved;
461
+ }
462
+ /** "a string", "an object", "null" — for messages about a value of the wrong kind. */
463
+ function kindOf(value) {
464
+ if (value === null) return "null";
465
+ const kind = typeof value;
466
+ return /^[aeiou]/.test(kind) ? `an ${kind}` : `a ${kind}`;
467
+ }
468
+ /**
469
+ * An http(s) baseURL must name a host. `https://${HOST}/api` with HOST unset
470
+ * is "https:///api", which a URL parser reads as host "api" — and a relative
471
+ * path appended to "https://" chooses the host itself.
472
+ */
473
+ function checkBaseURL(baseURL) {
474
+ if (!/^https?:\/\//i.test(baseURL)) return;
475
+ if (!/^https?:\/\/(?:[/\\?#]|$)/i.test(baseURL) && httpOrigin(baseURL)) return;
476
+ throw new TypeError(`arthron: baseURL "${withoutSecrets(baseURL)}" has no host — pass a full URL such as "https://api.example.com" (an unset environment variable?)`);
350
477
  }
351
478
  //#endregion
352
479
  //#region src/client/send.ts
353
480
  /**
354
481
  * The global fetch rejects a malformed URL or method with a TypeError that
355
- * looks just like a network failure (status 0, retried). A bodiless `Request`
356
- * runs the same checks first, so those become argument errors instead. Only
357
- * for the global fetch: an injected one owns its URL rules (SvelteKit's
358
- * server-side `fetch` accepts relative URLs, for one).
482
+ * looks just like a network failure (status 0, retried). Once fetch has
483
+ * failed, a bodiless `Request` runs the same checks, so those become argument
484
+ * errors instead — at no cost to a call that goes through. Only for the
485
+ * global fetch: an injected one owns its URL rules (SvelteKit's server-side
486
+ * `fetch` accepts relative URLs, for one).
359
487
  */
360
488
  function assertSendable(url, method) {
361
489
  if (typeof Request !== "function") return;
362
490
  try {
363
491
  new Request(url, { method });
364
492
  } catch (cause) {
493
+ const safeCause = redactErrorUrl(cause, url);
365
494
  const reason = redactUrlIn(cause instanceof Error ? cause.message : String(cause), url);
366
- throw new TypeError(`arthron: cannot send ${method} ${redactUrl(url)} (${reason}) — use an absolute URL or a baseURL with a scheme (https://…), and a valid HTTP method`, { cause });
495
+ throw new TypeError(`arthron: cannot send ${method} ${redactUrl(url)} (${reason}) — use an absolute URL or a baseURL with a scheme (https://…), and a valid HTTP method`, { cause: safeCause });
367
496
  }
368
497
  }
369
498
  /**
@@ -386,23 +515,23 @@ function failedByDefault(res, init) {
386
515
  }
387
516
  /** The one place a failure becomes an ArthronError. Cancellation passes through. */
388
517
  async function send(resolved, ctx, throwOn) {
389
- const fetchImpl = ctx.fetch ?? resolved.fetch ?? globalThis.fetch;
390
- if (resolved.fetch === void 0) {
391
- const method = ctx.init.method ?? "GET";
392
- assertSendable(ctx.url, method);
393
- assertStreamable(ctx.url, method, ctx.init.body);
394
- }
518
+ const fetchFn = ctx.fetchFn ?? resolved.fetchFn ?? globalThis.fetch;
519
+ if (typeof fetchFn !== "function") throw new TypeError(`arthron: requestContext.fetchFn must be a function with fetch's signature, got ${fetchFn === null ? "null" : `a ${typeof fetchFn}`} — a plugin set it; return { ...requestContext, fetchFn: fn }`);
520
+ const runtimeFetch = resolved.fetchFn === void 0;
521
+ const method = ctx.init.method ?? "GET";
522
+ if (runtimeFetch) assertStreamable(ctx.url, method, ctx.init.body);
395
523
  const { signal } = ctx.init;
396
524
  let res;
397
525
  try {
398
- res = await fetchImpl(ctx.url, ctx.init);
526
+ res = await fetchFn(ctx.url, ctx.init);
399
527
  } catch (thrown) {
400
528
  if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
529
+ if (runtimeFetch) assertSendable(ctx.url, method);
401
530
  throw networkError(thrown, ctx);
402
531
  }
403
532
  if (res.ok) return res;
404
533
  if (!(throwOn ? throwOn(res) : failedByDefault(res, ctx.init))) return res;
405
- const error = await responseError(res, ctx, resolved.errorDetail);
534
+ const error = await responseError(res, ctx, resolved.extractDescription);
406
535
  if (signal?.aborted) throw signal.reason;
407
536
  throw error;
408
537
  }
@@ -421,19 +550,30 @@ const STANDARD_METHODS = /* @__PURE__ */ new Set([
421
550
  "QUERY"
422
551
  ]);
423
552
  /**
424
- * Build a client from config. The result is immutable — `use` and `extend`
553
+ * 1.x per-call option names and what replaced them. Left in init they would
554
+ * reach fetch and be ignored: {id} sent unfilled, the query left off.
555
+ */
556
+ const REPLACED_PER_CALL = {
557
+ path: "pathParams",
558
+ params: "queryParams"
559
+ };
560
+ /**
561
+ * Build a client from its options. The result is immutable — `use` and `extend`
425
562
  * return new clients and never touch the one they were called on.
426
563
  */
427
- function arthron(config = {}) {
428
- const resolved = resolveConfig(config);
564
+ function arthron(clientOptions = {}) {
565
+ const resolved = resolveClientOptions(clientOptions);
429
566
  const initDefaults = fetchDefaults(resolved);
430
- const dispatch = (path, method, rawBody, { params, headers: callHeaders, meta, throwOn = resolved.throwOn, path: pathValues, ...init }) => {
567
+ const allowed = resolved.allowedOrigins && originSet(resolved.allowedOrigins);
568
+ const pipeline = createPipeline(resolved.plugins);
569
+ const call = (path, method, rawBody, { queryParams, headers: callHeaders, meta, throwOn = resolved.throwOn, pathParams, ...init }) => {
431
570
  try {
571
+ for (const [old, now] of Object.entries(REPLACED_PER_CALL)) if (init[old] !== void 0) throw new TypeError(`arthron: the ${old} option was replaced by ${now} in 2.0 — pass { ${now}: { … } }`);
432
572
  const upper = method.toUpperCase();
433
573
  const verb = STANDARD_METHODS.has(upper) ? upper : method;
434
- if (rawBody !== void 0 && BODYLESS_METHODS.has(upper)) throw new TypeError(`arthron: ${upper} requests cannot carry a body — send it as params (query) or switch to POST`);
435
- if (pathValues) path = fillPath(path, pathValues);
436
- const url = resolveUrl(resolved.baseURL, path, params, resolved.allowAbsoluteUrls);
574
+ if (rawBody !== void 0 && BODYLESS_METHODS.has(upper)) throw new TypeError(`arthron: ${upper} requests cannot carry a body — send it as queryParams or switch to POST`);
575
+ if (pathParams) path = fillPath(path, pathParams);
576
+ const url = resolveUrl(resolved.baseURL, path, queryParams, allowed);
437
577
  const defaults = leavesBase(resolved.baseURL, path) ? void 0 : resolved.headers;
438
578
  const { body, headers } = serializeBody(rawBody, mergeHeaders(defaults, callHeaders));
439
579
  const requestInit = {
@@ -444,66 +584,44 @@ function arthron(config = {}) {
444
584
  };
445
585
  if (body !== void 0) requestInit.body = body;
446
586
  if (isReadableStream(body)) Object.assign(requestInit, { duplex: "half" });
447
- return toArthronPromise(runPipeline(resolved.use, {
587
+ return toResponsePromise(pipeline({
448
588
  url,
449
589
  init: requestInit,
450
590
  meta: { ...meta },
451
- fetch: resolved.fetch ?? globalThis.fetch
591
+ fetchFn: resolved.fetchFn ?? globalThis.fetch
452
592
  }, (ctx) => send(resolved, ctx, throwOn)), init.signal);
453
593
  } catch (thrown) {
454
- return toArthronPromise(Promise.reject(thrown));
594
+ return toResponsePromise(Promise.reject(thrown));
455
595
  }
456
596
  };
457
- const derive = (next) => {
597
+ const derive = (changes) => {
458
598
  return arthron({
459
599
  ...resolved,
460
- ...next,
461
- headers: mergeHeaders(resolved.headers, next.headers),
462
- use: addPlugins(resolved.use, next.use ?? [])
600
+ ...changes,
601
+ headers: mergeHeaders(resolved.headers, changes.headers),
602
+ plugins: addPlugins(resolved.plugins, changes.plugins ?? [])
463
603
  });
464
604
  };
465
605
  return {
466
- get: (path, init = {}) => dispatch(path, "GET", void 0, init),
467
- delete: (path, init = {}) => dispatch(path, "DELETE", void 0, init),
468
- head: (path, init = {}) => dispatch(path, "HEAD", void 0, init),
469
- post: (path, body, init = {}) => dispatch(path, "POST", body, init),
470
- put: (path, body, init = {}) => dispatch(path, "PUT", body, init),
471
- patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
472
- query: (path, body, init = {}) => dispatch(path, "QUERY", body, init),
473
- request: (path, init) => {
474
- const { method, body, ...rest } = init;
475
- if (!method) return toArthronPromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request() needs init.method — e.g. request(path, { method: \"OPTIONS\" }); use get/post/... for the common verbs")));
476
- return dispatch(path, method, body, rest);
606
+ get: (path, requestOptions = {}) => call(path, "GET", void 0, requestOptions),
607
+ delete: (path, requestOptions = {}) => call(path, "DELETE", void 0, requestOptions),
608
+ head: (path, requestOptions = {}) => call(path, "HEAD", void 0, requestOptions),
609
+ post: (path, body, requestOptions = {}) => call(path, "POST", body, requestOptions),
610
+ put: (path, body, requestOptions = {}) => call(path, "PUT", body, requestOptions),
611
+ patch: (path, body, requestOptions = {}) => call(path, "PATCH", body, requestOptions),
612
+ query: (path, body, requestOptions = {}) => call(path, "QUERY", body, requestOptions),
613
+ request: (path, requestOptions) => {
614
+ const { method, body, ...rest } = requestOptions;
615
+ if (!method) return toResponsePromise(Promise.reject(/* @__PURE__ */ new TypeError("arthron: request() needs a method — e.g. request(path, { method: \"OPTIONS\" }); use get/post/... for the common verbs")));
616
+ return call(path, method, body, rest);
477
617
  },
478
- use: (...plugins) => derive({ use: plugins }),
479
- extend: (next) => derive(next),
480
- without: (...names) => arthron({
618
+ use: (...plugins) => derive({ plugins }),
619
+ extend: (changes) => derive(changes),
620
+ without: (...pluginNames) => arthron({
481
621
  ...resolved,
482
- use: resolved.use.filter((p) => p.name === void 0 || !names.includes(p.name))
622
+ plugins: resolved.plugins.filter((p) => !pluginNames.includes(p.pluginName))
483
623
  })
484
624
  };
485
625
  }
486
626
  //#endregion
487
- //#region src/pipeline/define-plugin.ts
488
- /** Identity helper: gives a middleware written in its own file the right parameter types. */
489
- const defineMiddleware = (middleware) => middleware;
490
- /** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
491
- const defineInterceptor = {
492
- request: (interceptor) => interceptor,
493
- response: (interceptor) => interceptor,
494
- error: (interceptor) => interceptor
495
- };
496
- /** Identity helper: type-checks a plugin object literal without an annotation. */
497
- const definePlugin = (plugin) => plugin;
498
- //#endregion
499
- //#region src/index.ts
500
- /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
501
- const logging = logging$1;
502
- /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
503
- const retry = retry$1;
504
- /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
505
- const timeout = timeout$1;
506
- //#endregion
507
- export { ArthronError, SchemaError, arthron, defineInterceptor, defineMiddleware, definePlugin, isArthronError, logging, retry, timeout };
508
-
509
- //# sourceMappingURL=index.mjs.map
627
+ export { ArthronError, SchemaMismatchError, arthron, createErrorInterceptor, createMiddleware, createPlugin, createRequestInterceptor, createResponseInterceptor, isArthronError };