arthron 1.1.0 → 2.0.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.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as redactUrlIn, a as isCancellation, d as serializeBody, f as ArthronError, g as redactUrl, h as leavesBase, i as cancellationError, l as isReadableStream, m as fillPath, n as retry$1, o as networkError, p as isArthronError, r as logging$1, s as responseError, t as timeout$1, u as runtimeStreamsRequestBody, v as resolveUrl } from "./plugins-ChNZ2Q8t.mjs";
1
+ import { C as withoutSecrets, S as resolveUrl, _ as leavesBase, a as responseError, b as redactUrl, c as isReadableStream, d as tagOf, f as ArthronError, h as httpOrigin, i as redactErrorUrl, l as runtimeStreamsRequestBody, m as fillPath, n as isCancellation, p as isArthronError, r as networkError, t as cancellationError, u as serializeBody, x as redactUrlIn, y as originSet } 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,32 +23,43 @@ 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 }) {
50
+ function cloneContext({ url, init, meta, fetchFn }) {
42
51
  const copy = {
43
52
  ...init,
44
53
  headers: toHeaderRecord(init.headers)
45
54
  };
46
55
  if (init.body) copy.body = cloneBody(init.body);
47
- return {
56
+ const out = {
48
57
  url,
49
58
  init: copy,
50
59
  meta: { ...meta }
51
60
  };
61
+ if (fetchFn) out.fetchFn = fetchFn;
62
+ return out;
52
63
  }
53
64
  /**
54
65
  * Put headers back into a plain lowercase record. A plugin written in JS (or
@@ -56,7 +67,7 @@ function cloneContext({ url, init, meta }) {
56
67
  * `{ ...ctx.init.headers }` would otherwise drop every header.
57
68
  */
58
69
  function normalize(ctx, previous) {
59
- return {
70
+ const out = {
60
71
  ...ctx,
61
72
  init: {
62
73
  ...ctx.init,
@@ -64,6 +75,18 @@ function normalize(ctx, previous) {
64
75
  },
65
76
  meta: ctx.meta ?? previous.meta
66
77
  };
78
+ const fetchFn = ctx.fetchFn ?? previous.fetchFn;
79
+ if (fetchFn) out.fetchFn = fetchFn;
80
+ return out;
81
+ }
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 }`);
67
90
  }
68
91
  /**
69
92
  * A plugin written in JS (or with a cast) that forgets to return would hand
@@ -75,7 +98,9 @@ function returned(value, who, what, cause, verb = "returned") {
75
98
  throw new TypeError(`arthron: ${who} ${verb} ${value} — return ${what}`, cause === void 0 ? void 0 : { cause });
76
99
  }
77
100
  /**
78
- * 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.
79
104
  *
80
105
  * Middleware wraps the whole call, outermost first in registration order.
81
106
  * Each attempt below it runs request interceptors → `send` → response
@@ -83,51 +108,61 @@ function returned(value, who, what, cause, verb = "returned") {
83
108
  * they return is thrown. Anything else that is thrown — cancellation, a bug
84
109
  * in a plugin — propagates untouched.
85
110
  */
86
- async function runPipeline(plugins, ctx, send) {
111
+ function createPipeline(plugins) {
87
112
  const middlewares = [];
88
- const request = [];
89
- const response = [];
90
- const error = [];
113
+ const requestInterceptors = [];
114
+ const responseInterceptors = [];
115
+ const errorInterceptors = [];
91
116
  for (const plugin of plugins) {
92
117
  if (plugin.middleware) middlewares.push(plugin.middleware);
93
- if (plugin.request) request.push(plugin.request);
94
- if (plugin.response) response.push(plugin.response);
95
- 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);
96
121
  }
97
- const attempt = async (input) => {
122
+ const attempt = async (input, send) => {
98
123
  let current = cloneContext(input);
99
- 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
+ }
100
129
  let res;
101
130
  try {
102
131
  res = await send(current);
103
132
  } catch (thrown) {
104
133
  if (!isArthronError(thrown)) throw thrown;
105
134
  let err = thrown;
106
- 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);
107
136
  throw err;
108
137
  }
109
- 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)");
110
139
  return res;
111
140
  };
112
- const dispatch = async (index, input) => {
141
+ const runFrom = async (index, input, send) => {
113
142
  const middleware = middlewares[index];
114
- if (!middleware) return attempt(input);
115
- return returned(await middleware(input, (next) => dispatch(index + 1, {
116
- ...next,
117
- meta: next.meta ?? input.meta
118
- })), "a middleware", "the Response, usually `return next(ctx)`", void 0, "resolved with");
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");
146
+ const ctx = {
147
+ ...passed,
148
+ meta: passed.meta ?? input.meta
149
+ };
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");
119
154
  };
120
- return dispatch(0, ctx);
155
+ return (ctx, send) => runFrom(0, ctx, send);
121
156
  }
122
157
  //#endregion
123
158
  //#region src/response/schema-validation.ts
124
159
  const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
125
160
  /**
126
- * `.json(schema)` got a body that does not match. Not an ArthronError: the
127
- * 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.
128
163
  */
129
- var SchemaError = class extends Error {
130
- name = "SchemaError";
164
+ var SchemaMismatchError = class extends Error {
165
+ name = "SchemaMismatchError";
131
166
  /** Every problem the validator reported, with the path to it. */
132
167
  issues;
133
168
  constructor(issues) {
@@ -138,10 +173,10 @@ var SchemaError = class extends Error {
138
173
  this.issues = issues;
139
174
  }
140
175
  };
141
- /** Validate `data`; resolve with the schema's output or reject with a SchemaError. */
142
- async function validate(schema, data) {
143
- const result = await schema["~standard"].validate(data);
144
- 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);
145
180
  return result.value;
146
181
  }
147
182
  //#endregion
@@ -196,24 +231,24 @@ async function* lines(response, maxLineLength) {
196
231
  * Provider sentinels like
197
232
  * `[DONE]` are passed through untouched — they are not part of the spec.
198
233
  */
199
- async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxEventSize = DEFAULT_LIMIT } = {}) {
234
+ async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxDataLength = DEFAULT_LIMIT } = {}) {
200
235
  let data = [];
201
236
  let size = 0;
202
- let event;
237
+ let type;
203
238
  let id;
204
239
  let lastEventId = "";
205
- let retry;
240
+ let reconnectionTime;
206
241
  const flush = () => {
207
242
  const out = data.length === 0 ? void 0 : {
243
+ type: type || "message",
208
244
  data: data.join("\n"),
209
245
  lastEventId
210
246
  };
211
- if (out && event !== void 0) out.event = event;
212
247
  if (out && id !== void 0) out.id = id;
213
- if (out && retry !== void 0) out.retry = retry;
248
+ if (out && reconnectionTime !== void 0) out.reconnectionTime = reconnectionTime;
214
249
  data = [];
215
250
  size = 0;
216
- event = id = void 0;
251
+ type = id = void 0;
217
252
  return out;
218
253
  };
219
254
  for await (const line of lines(response, maxLineLength)) {
@@ -230,16 +265,16 @@ async function* parseSse(response, { maxLineLength = DEFAULT_LIMIT, maxEventSize
230
265
  switch (field) {
231
266
  case "data":
232
267
  size += value.length + 1;
233
- 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`);
268
+ 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`);
234
269
  data.push(value);
235
270
  break;
236
271
  case "event":
237
- event = value;
272
+ type = value;
238
273
  break;
239
274
  case "id":
240
275
  if (!value.includes("\0")) id = lastEventId = value;
241
276
  break;
242
- case "retry": if (/^\d+$/.test(value)) retry = Number(value);
277
+ case "retry": if (/^\d+$/.test(value)) reconnectionTime = Number(value);
243
278
  }
244
279
  }
245
280
  }
@@ -254,7 +289,7 @@ async function* parseNdjson(response, { maxLineLength = DEFAULT_LIMIT } = {}) {
254
289
  }
255
290
  }
256
291
  //#endregion
257
- //#region src/response/arthron-promise.ts
292
+ //#region src/response/response-promise.ts
258
293
  /** Defer the Response until iteration starts, so the helper itself never rejects. */
259
294
  function iterate(promise, parse, signal) {
260
295
  promise.catch(() => {});
@@ -270,14 +305,14 @@ function iterate(promise, parse, signal) {
270
305
  * Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
271
306
  * without a helper still yields the untouched `Response`.
272
307
  */
273
- function toArthronPromise(promise, signal) {
274
- const read = (body) => promise.then(body).catch((error) => {
308
+ function toResponsePromise(promise, signal) {
309
+ const read = (readAs) => promise.then(readAs).catch((error) => {
275
310
  throw cancellationError(error, signal);
276
311
  });
277
312
  return Object.assign(promise, {
278
313
  json: ((schema) => read(async (res) => {
279
- const data = await res.json();
280
- return schema ? validate(schema, data) : data;
314
+ const body = await res.json();
315
+ return schema ? validate(schema, body) : body;
281
316
  })),
282
317
  text: () => read((res) => res.text()),
283
318
  blob: () => read((res) => res.blob()),
@@ -288,19 +323,75 @@ function toArthronPromise(promise, signal) {
288
323
  });
289
324
  }
290
325
  //#endregion
291
- //#region src/client/resolve-config.ts
326
+ //#region src/pipeline/create-plugin.ts
327
+ /** The slots a plugin may fill. */
328
+ const SLOTS = [
329
+ "middleware",
330
+ "requestInterceptor",
331
+ "responseInterceptor",
332
+ "errorInterceptor"
333
+ ];
334
+ /** 1.x slot names: a plugin that still uses one would have it ignored. */
335
+ const REPLACED_SLOTS = {
336
+ request: "requestInterceptor",
337
+ response: "responseInterceptor",
338
+ error: "errorInterceptor"
339
+ };
292
340
  /**
293
- * Config keys that configure arthron itself; every other key is a fetch
294
- * default. Typed so that a new config key fails to compile until it is
295
- * listed here — otherwise it would be sent to fetch.
341
+ * Throws a TypeError for what a plugin can get wrong where TypeScript does
342
+ * not look — JS, a cast, an object built at runtime: not an object, no
343
+ * pluginName, a key it does not take (a typo, a 1.x name), a slot that is not
344
+ * a function, or nothing to run. `createPlugin` runs it where the plugin is
345
+ * written; `use` and `plugins` run it again on whatever they are given.
346
+ */
347
+ function checkPlugin(plugin) {
348
+ 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 })`);
349
+ const fields = plugin;
350
+ const own = (key) => Object.hasOwn(fields, key) ? fields[key] : void 0;
351
+ const given = fields.pluginName ?? own("name");
352
+ const label = typeof given === "string" && given !== "" ? `plugin "${given}"` : "a plugin";
353
+ if (own("name") !== void 0) throw new TypeError(`arthron: ${label}: the name field is called pluginName since 2.0 — rename it in the plugin`);
354
+ 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`);
355
+ if (typeof fields.pluginName !== "string" || fields.pluginName === "") throw new TypeError("arthron: a plugin needs a pluginName — e.g. createPlugin({ pluginName: \"trace\", … })");
356
+ 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({ … })`);
357
+ for (const slot of SLOTS) if (fields[slot] !== void 0 && typeof fields[slot] !== "function") throw new TypeError(`arthron: ${label}: ${slot} must be a function`);
358
+ if (SLOTS.every((slot) => fields[slot] === void 0)) throw new TypeError(`arthron: ${label} has nothing to run — add middleware, requestInterceptor, responseInterceptor or errorInterceptor`);
359
+ }
360
+ /**
361
+ * A plugin, checked where it is written and typed without annotations:
362
+ * `createPlugin({ pluginName: "trace", requestInterceptor: (requestContext) => … })`.
363
+ */
364
+ function createPlugin(plugin) {
365
+ checkPlugin(plugin);
366
+ return plugin;
367
+ }
368
+ function aFunction(value, helper, example) {
369
+ if (typeof value !== "function") throw new TypeError(`arthron: ${helper} needs a function — e.g. ${example}`);
370
+ return value;
371
+ }
372
+ /** A middleware written on its own, typed without annotations. */
373
+ const createMiddleware = (middleware) => aFunction(middleware, "createMiddleware", "createMiddleware((requestContext, next) => next(requestContext))");
374
+ /** A request interceptor written on its own, typed without annotations. */
375
+ const createRequestInterceptor = (interceptor) => aFunction(interceptor, "createRequestInterceptor", "createRequestInterceptor((requestContext) => requestContext)");
376
+ /** A response interceptor written on its own, typed without annotations. */
377
+ const createResponseInterceptor = (interceptor) => aFunction(interceptor, "createResponseInterceptor", "createResponseInterceptor((response) => response)");
378
+ /** An error interceptor written on its own, typed without annotations. */
379
+ const createErrorInterceptor = (interceptor) => aFunction(interceptor, "createErrorInterceptor", "createErrorInterceptor((error) => error)");
380
+ //#endregion
381
+ //#region src/client/resolve-options.ts
382
+ /**
383
+ * Option keys that configure arthron itself; every other key is a fetch
384
+ * default. Typed so that a new option key fails to compile until it is
385
+ * listed here — otherwise it would be sent to fetch. `headers` is a fetch
386
+ * field too, but one with arthron's own merge rules.
296
387
  */
297
388
  const OWN_KEYS = {
298
389
  baseURL: true,
299
- allowAbsoluteUrls: true,
390
+ allowedOrigins: true,
300
391
  headers: true,
301
- fetch: true,
302
- use: true,
303
- errorDetail: true,
392
+ fetchFn: true,
393
+ plugins: true,
394
+ extractDescription: true,
304
395
  throwOn: true
305
396
  };
306
397
  /** Fields that belong to one request, refused as client defaults. */
@@ -310,15 +401,27 @@ const PER_CALL_ONLY = [
310
401
  "method"
311
402
  ];
312
403
  /**
313
- * Add plugins to a list. A named plugin takes the place of the one already
314
- * there with that name — same position, so a replaced `logging()` stays
315
- * outermost and a second `retry()` does not multiply attempts. Unnamed
316
- * plugins append.
404
+ * 1.x config keys and what replaced them. Left alone, each would become an
405
+ * unknown key, then a fetch default, and be ignored: plugins not applied, a
406
+ * test's fake fetch passed over for the real network.
407
+ */
408
+ const REPLACED = {
409
+ fetch: "fetchFn",
410
+ use: "plugins",
411
+ errorDetail: "extractDescription",
412
+ allowAbsoluteUrls: "allowedOrigins"
413
+ };
414
+ /**
415
+ * Add plugins to a list, each checked as `createPlugin` checks it. A plugin
416
+ * takes the place of the one already there with its pluginName — same position,
417
+ * so a replaced `logging()` stays outermost and a second `retry()` does not
418
+ * multiply attempts. Other names append.
317
419
  */
318
420
  function addPlugins(base, added) {
319
421
  const out = [...base];
320
422
  for (const plugin of added) {
321
- const at = plugin.name === void 0 ? -1 : out.findIndex((p) => p.name === plugin.name);
423
+ checkPlugin(plugin);
424
+ const at = out.findIndex((p) => p.pluginName === plugin.pluginName);
322
425
  if (at === -1) out.push(plugin);
323
426
  else out[at] = plugin;
324
427
  }
@@ -329,29 +432,55 @@ function fetchDefaults(config) {
329
432
  return Object.fromEntries(Object.entries(config).filter(([key, value]) => value !== void 0 && !(key in OWN_KEYS)));
330
433
  }
331
434
  /** Normalize once so `extend` can merge headers and add plugins cheaply. */
332
- function resolveConfig(config) {
435
+ function resolveClientOptions(config) {
333
436
  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 }))`);
334
- const { headers, use, ...rest } = config;
335
- return {
437
+ 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} })`);
438
+ const { headers, plugins, allowedOrigins, ...rest } = config;
439
+ 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 })`);
440
+ if (rest.baseURL !== void 0) checkBaseURL(rest.baseURL);
441
+ 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"]`);
442
+ const record = toHeaderRecord(headers);
443
+ 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");
444
+ const resolved = {
336
445
  ...rest,
337
- headers: toHeaderRecord(headers),
338
- use: addPlugins([], use ?? [])
446
+ headers: record,
447
+ plugins: addPlugins([], plugins ?? [])
339
448
  };
449
+ if (allowedOrigins !== void 0) resolved.allowedOrigins = [...allowedOrigins];
450
+ return resolved;
451
+ }
452
+ /** "a string", "an object", "null" — for messages about a value of the wrong kind. */
453
+ function kindOf(value) {
454
+ if (value === null) return "null";
455
+ const kind = typeof value;
456
+ return /^[aeiou]/.test(kind) ? `an ${kind}` : `a ${kind}`;
457
+ }
458
+ /**
459
+ * An http(s) baseURL must name a host. `https://${HOST}/api` with HOST unset
460
+ * is "https:///api", which a URL parser reads as host "api" — and a relative
461
+ * path appended to "https://" chooses the host itself.
462
+ */
463
+ function checkBaseURL(baseURL) {
464
+ if (!/^https?:\/\//i.test(baseURL)) return;
465
+ if (!/^https?:\/\/(?:[/\\?#]|$)/i.test(baseURL) && httpOrigin(baseURL)) return;
466
+ throw new TypeError(`arthron: baseURL "${withoutSecrets(baseURL)}" has no host — pass a full URL such as "https://api.example.com" (an unset environment variable?)`);
340
467
  }
341
468
  //#endregion
342
469
  //#region src/client/send.ts
343
470
  /**
344
471
  * The global fetch rejects a malformed URL or method with a TypeError that
345
- * looks just like a network failure (status 0, retried). A bodiless `Request`
346
- * runs the same checks first, so those become argument errors instead. Only
347
- * for the global fetch: an injected one owns its URL rules (SvelteKit's
348
- * server-side `fetch` accepts relative URLs, for one).
472
+ * looks just like a network failure (status 0, retried). Once fetch has
473
+ * failed, a bodiless `Request` runs the same checks, so those become argument
474
+ * errors instead — at no cost to a call that goes through. Only for the
475
+ * global fetch: an injected one owns its URL rules (SvelteKit's server-side
476
+ * `fetch` accepts relative URLs, for one).
349
477
  */
350
478
  function assertSendable(url, method) {
351
479
  if (typeof Request !== "function") return;
352
480
  try {
353
481
  new Request(url, { method });
354
482
  } catch (cause) {
483
+ redactErrorUrl(cause, url);
355
484
  const reason = redactUrlIn(cause instanceof Error ? cause.message : String(cause), url);
356
485
  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 });
357
486
  }
@@ -376,23 +505,23 @@ function failedByDefault(res, init) {
376
505
  }
377
506
  /** The one place a failure becomes an ArthronError. Cancellation passes through. */
378
507
  async function send(resolved, ctx, throwOn) {
379
- const fetchImpl = resolved.fetch ?? globalThis.fetch;
380
- if (resolved.fetch === void 0) {
381
- const method = ctx.init.method ?? "GET";
382
- assertSendable(ctx.url, method);
383
- assertStreamable(ctx.url, method, ctx.init.body);
384
- }
508
+ const fetchFn = ctx.fetchFn ?? resolved.fetchFn ?? globalThis.fetch;
509
+ 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 }`);
510
+ const runtimeFetch = resolved.fetchFn === void 0;
511
+ const method = ctx.init.method ?? "GET";
512
+ if (runtimeFetch) assertStreamable(ctx.url, method, ctx.init.body);
385
513
  const { signal } = ctx.init;
386
514
  let res;
387
515
  try {
388
- res = await fetchImpl(ctx.url, ctx.init);
516
+ res = await fetchFn(ctx.url, ctx.init);
389
517
  } catch (thrown) {
390
518
  if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
519
+ if (runtimeFetch) assertSendable(ctx.url, method);
391
520
  throw networkError(thrown, ctx);
392
521
  }
393
522
  if (res.ok) return res;
394
523
  if (!(throwOn ? throwOn(res) : failedByDefault(res, ctx.init))) return res;
395
- const error = await responseError(res, ctx, resolved.errorDetail);
524
+ const error = await responseError(res, ctx, resolved.extractDescription);
396
525
  if (signal?.aborted) throw signal.reason;
397
526
  throw error;
398
527
  }
@@ -411,19 +540,30 @@ const STANDARD_METHODS = /* @__PURE__ */ new Set([
411
540
  "QUERY"
412
541
  ]);
413
542
  /**
414
- * Build a client from config. The result is immutable — `use` and `extend`
543
+ * 1.x per-call option names and what replaced them. Left in init they would
544
+ * reach fetch and be ignored: {id} sent unfilled, the query left off.
545
+ */
546
+ const REPLACED_PER_CALL = {
547
+ path: "pathParams",
548
+ params: "queryParams"
549
+ };
550
+ /**
551
+ * Build a client from its options. The result is immutable — `use` and `extend`
415
552
  * return new clients and never touch the one they were called on.
416
553
  */
417
- function arthron(config = {}) {
418
- const resolved = resolveConfig(config);
554
+ function arthron(clientOptions = {}) {
555
+ const resolved = resolveClientOptions(clientOptions);
419
556
  const initDefaults = fetchDefaults(resolved);
420
- const dispatch = (path, method, rawBody, { params, headers: callHeaders, meta, throwOn = resolved.throwOn, path: pathValues, ...init }) => {
557
+ const allowed = resolved.allowedOrigins && originSet(resolved.allowedOrigins);
558
+ const pipeline = createPipeline(resolved.plugins);
559
+ const call = (path, method, rawBody, { queryParams, headers: callHeaders, meta, throwOn = resolved.throwOn, pathParams, ...init }) => {
421
560
  try {
561
+ 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}: { … } }`);
422
562
  const upper = method.toUpperCase();
423
563
  const verb = STANDARD_METHODS.has(upper) ? upper : method;
424
- 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`);
425
- if (pathValues) path = fillPath(path, pathValues);
426
- const url = resolveUrl(resolved.baseURL, path, params, resolved.allowAbsoluteUrls);
564
+ 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`);
565
+ if (pathParams) path = fillPath(path, pathParams);
566
+ const url = resolveUrl(resolved.baseURL, path, queryParams, allowed);
427
567
  const defaults = leavesBase(resolved.baseURL, path) ? void 0 : resolved.headers;
428
568
  const { body, headers } = serializeBody(rawBody, mergeHeaders(defaults, callHeaders));
429
569
  const requestInit = {
@@ -434,65 +574,44 @@ function arthron(config = {}) {
434
574
  };
435
575
  if (body !== void 0) requestInit.body = body;
436
576
  if (isReadableStream(body)) Object.assign(requestInit, { duplex: "half" });
437
- return toArthronPromise(runPipeline(resolved.use, {
577
+ return toResponsePromise(pipeline({
438
578
  url,
439
579
  init: requestInit,
440
- meta: { ...meta }
580
+ meta: { ...meta },
581
+ fetchFn: resolved.fetchFn ?? globalThis.fetch
441
582
  }, (ctx) => send(resolved, ctx, throwOn)), init.signal);
442
583
  } catch (thrown) {
443
- return toArthronPromise(Promise.reject(thrown));
584
+ return toResponsePromise(Promise.reject(thrown));
444
585
  }
445
586
  };
446
- const derive = (next) => {
587
+ const derive = (changes) => {
447
588
  return arthron({
448
589
  ...resolved,
449
- ...next,
450
- headers: mergeHeaders(resolved.headers, next.headers),
451
- use: addPlugins(resolved.use, next.use ?? [])
590
+ ...changes,
591
+ headers: mergeHeaders(resolved.headers, changes.headers),
592
+ plugins: addPlugins(resolved.plugins, changes.plugins ?? [])
452
593
  });
453
594
  };
454
595
  return {
455
- get: (path, init = {}) => dispatch(path, "GET", void 0, init),
456
- delete: (path, init = {}) => dispatch(path, "DELETE", void 0, init),
457
- head: (path, init = {}) => dispatch(path, "HEAD", void 0, init),
458
- post: (path, body, init = {}) => dispatch(path, "POST", body, init),
459
- put: (path, body, init = {}) => dispatch(path, "PUT", body, init),
460
- patch: (path, body, init = {}) => dispatch(path, "PATCH", body, init),
461
- query: (path, body, init = {}) => dispatch(path, "QUERY", body, init),
462
- request: (path, init) => {
463
- const { method, body, ...rest } = init;
464
- 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")));
465
- return dispatch(path, method, body, rest);
596
+ get: (path, requestOptions = {}) => call(path, "GET", void 0, requestOptions),
597
+ delete: (path, requestOptions = {}) => call(path, "DELETE", void 0, requestOptions),
598
+ head: (path, requestOptions = {}) => call(path, "HEAD", void 0, requestOptions),
599
+ post: (path, body, requestOptions = {}) => call(path, "POST", body, requestOptions),
600
+ put: (path, body, requestOptions = {}) => call(path, "PUT", body, requestOptions),
601
+ patch: (path, body, requestOptions = {}) => call(path, "PATCH", body, requestOptions),
602
+ query: (path, body, requestOptions = {}) => call(path, "QUERY", body, requestOptions),
603
+ request: (path, requestOptions) => {
604
+ const { method, body, ...rest } = requestOptions;
605
+ 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")));
606
+ return call(path, method, body, rest);
466
607
  },
467
- use: (...plugins) => derive({ use: plugins }),
468
- extend: (next) => derive(next),
469
- without: (...names) => arthron({
608
+ use: (...plugins) => derive({ plugins }),
609
+ extend: (changes) => derive(changes),
610
+ without: (...pluginNames) => arthron({
470
611
  ...resolved,
471
- use: resolved.use.filter((p) => p.name === void 0 || !names.includes(p.name))
612
+ plugins: resolved.plugins.filter((p) => !pluginNames.includes(p.pluginName))
472
613
  })
473
614
  };
474
615
  }
475
616
  //#endregion
476
- //#region src/pipeline/define-plugin.ts
477
- /** Identity helper: gives a middleware written in its own file the right parameter types. */
478
- const defineMiddleware = (middleware) => middleware;
479
- /** Identity helpers for each interceptor kind — `defineInterceptor.request(fn)` etc. */
480
- const defineInterceptor = {
481
- request: (interceptor) => interceptor,
482
- response: (interceptor) => interceptor,
483
- error: (interceptor) => interceptor
484
- };
485
- /** Identity helper: type-checks a plugin object literal without an annotation. */
486
- const definePlugin = (plugin) => plugin;
487
- //#endregion
488
- //#region src/index.ts
489
- /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
490
- const logging = logging$1;
491
- /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
492
- const retry = retry$1;
493
- /** @deprecated Import from "arthron/plugins"; this export goes in the next major. */
494
- const timeout = timeout$1;
495
- //#endregion
496
- export { ArthronError, SchemaError, arthron, defineInterceptor, defineMiddleware, definePlugin, isArthronError, logging, retry, timeout };
497
-
498
- //# sourceMappingURL=index.mjs.map
617
+ export { ArthronError, SchemaMismatchError, arthron, createErrorInterceptor, createMiddleware, createPlugin, createRequestInterceptor, createResponseInterceptor, isArthronError };