@askrjs/fetch 0.4.0 → 0.4.2

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/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  //#region src/client.ts
2
2
  const media = (response) => response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? null;
3
3
  const compatible = (codec, type) => codec.kind === "empty" || codec.kind === "blob" || codec.kind === "arrayBuffer" || codec.kind === "stream" || !!type && codec.mediaTypes.some((expected) => expected === "*/*" || expected === type || expected === "text/*" && type.startsWith("text/") || expected === "+json" && type.endsWith("+json"));
4
+ const selectCodec = (codec, response) => codec.kind === "content" ? codec.variants?.[media(response) ?? ""] : codec;
4
5
  const validationFailure = (result) => "error" in result ? result.error : result.issues;
5
6
  async function decode(response, codec) {
6
7
  const type = media(response);
@@ -39,6 +40,58 @@ async function decode(response, codec) {
39
40
  }
40
41
  return value;
41
42
  }
43
+ function responseWithStreamCleanup(response, cleanup) {
44
+ const source = response.body;
45
+ if (!source) {
46
+ cleanup();
47
+ return response;
48
+ }
49
+ const reader = source.getReader();
50
+ let finished = false;
51
+ const finish = () => {
52
+ if (finished) return;
53
+ finished = true;
54
+ cleanup();
55
+ };
56
+ const body = new ReadableStream({
57
+ async pull(streamController) {
58
+ try {
59
+ const chunk = await reader.read();
60
+ if (chunk.done) {
61
+ finish();
62
+ streamController.close();
63
+ } else streamController.enqueue(chunk.value);
64
+ } catch (error) {
65
+ finish();
66
+ streamController.error(error);
67
+ }
68
+ },
69
+ async cancel(reason) {
70
+ finish();
71
+ return reader.cancel(reason);
72
+ }
73
+ });
74
+ const wrapped = new Response(body, {
75
+ status: response.status,
76
+ statusText: response.statusText,
77
+ headers: response.headers
78
+ });
79
+ Object.defineProperties(wrapped, {
80
+ url: {
81
+ value: response.url,
82
+ enumerable: true
83
+ },
84
+ redirected: {
85
+ value: response.redirected,
86
+ enumerable: true
87
+ },
88
+ type: {
89
+ value: response.type,
90
+ enumerable: true
91
+ }
92
+ });
93
+ return wrapped;
94
+ }
42
95
  function encode(value, codec, headers, requestedMediaType) {
43
96
  if (codec.validator) {
44
97
  const parsed = codec.validator.safeParse(value);
@@ -176,17 +229,19 @@ function createFetch(options = {}) {
176
229
  const controller = new AbortController();
177
230
  let timedOut = false;
178
231
  let timer;
232
+ let streamOwnsCleanup = false;
179
233
  const abort = () => controller.abort(call.signal?.reason);
234
+ const cleanup = () => {
235
+ if (timer) clearTimeout(timer);
236
+ call.signal?.removeEventListener("abort", abort);
237
+ };
238
+ controller.signal.addEventListener("abort", cleanup, { once: true });
180
239
  call.signal?.addEventListener("abort", abort, { once: true });
181
- if (call.signal?.aborted) abort();
182
240
  if (timeout !== void 0) timer = setTimeout(() => {
183
241
  timedOut = true;
184
242
  controller.abort(new DOMException("Timed out", "TimeoutError"));
185
243
  }, timeout);
186
- const cleanup = () => {
187
- if (timer) clearTimeout(timer);
188
- call.signal?.removeEventListener("abort", abort);
189
- };
244
+ if (call.signal?.aborted) abort();
190
245
  if (controller.signal.aborted) {
191
246
  cleanup();
192
247
  return failure("abort", controller.signal.reason, url);
@@ -216,7 +271,14 @@ function createFetch(options = {}) {
216
271
  const codec = response.ok ? call.responses?.[response.status] ?? call.response : call.errors?.[response.status] ?? call.errors?.default;
217
272
  if (!codec) return failure("decode", /* @__PURE__ */ new TypeError(`No codec for status ${response.status}`), url, response);
218
273
  try {
219
- const data = await decode(response.clone(), codec);
274
+ const selected = selectCodec(codec, response);
275
+ let data = await decode(selected?.kind === "stream" ? response : response.clone(), codec);
276
+ let resultResponse = response;
277
+ if (selected?.kind === "stream" && response.body) {
278
+ resultResponse = responseWithStreamCleanup(response, cleanup);
279
+ streamOwnsCleanup = true;
280
+ data = resultResponse.body;
281
+ }
220
282
  return response.ok ? {
221
283
  ok: true,
222
284
  kind: "success",
@@ -224,8 +286,8 @@ function createFetch(options = {}) {
224
286
  data,
225
287
  mediaType: media(response),
226
288
  headers: response.headers,
227
- url: response.url || url,
228
- response
289
+ url: resultResponse.url || url,
290
+ response: resultResponse
229
291
  } : {
230
292
  ok: false,
231
293
  kind: "http",
@@ -233,8 +295,8 @@ function createFetch(options = {}) {
233
295
  error: data,
234
296
  mediaType: media(response),
235
297
  headers: response.headers,
236
- url: response.url || url,
237
- response
298
+ url: resultResponse.url || url,
299
+ response: resultResponse
238
300
  };
239
301
  } catch (error) {
240
302
  return failure("decode", error, url, response);
@@ -255,7 +317,7 @@ function createFetch(options = {}) {
255
317
  } catch (error) {
256
318
  return failure("middleware", error, url);
257
319
  } finally {
258
- cleanup();
320
+ if (!streamOwnsCleanup) cleanup();
259
321
  }
260
322
  };
261
323
  }
package/dist/types.d.ts CHANGED
@@ -97,6 +97,7 @@ export type SuccessResult<T = unknown, S extends number = number> = {
97
97
  mediaType: string | null;
98
98
  headers: Headers;
99
99
  url: string;
100
+ /** For stream codecs, this body is the same one-shot stream exposed by `data`. */
100
101
  response: Response;
101
102
  };
102
103
  /** A non-2xx fetch outcome where the server responded with a decodable error body. */
@@ -108,6 +109,7 @@ export type HttpResult<T = unknown, S extends number = number> = {
108
109
  mediaType: string | null;
109
110
  headers: Headers;
110
111
  url: string;
112
+ /** For stream codecs, this body is the same one-shot stream exposed by `error`. */
111
113
  response: Response;
112
114
  };
113
115
  /** Categorizes why a fetch could not produce an {@link HttpResult} or {@link SuccessResult}. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/fetch",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Function-first typed HTTP contracts and clients for Askr.",
5
5
  "keywords": [
6
6
  "askr",
@@ -61,7 +61,7 @@
61
61
  "prepublishOnly": "npm run check"
62
62
  },
63
63
  "devDependencies": {
64
- "@askrjs/schema": "^0.3.0",
64
+ "@askrjs/schema": ">=0.4.0 <0.5.0",
65
65
  "@types/node": "^26.3.0",
66
66
  "publint": "^0.3.24",
67
67
  "typescript": "^7.0.2",