repos / starfx

a micro-mvc framework for react apps
git clone https://github.com/neurosnap/starfx.git

starfx / src / mdw
Eric Bower  ·  2025-06-06

fetch.ts

  1import { sleep } from "effection";
  2import { safe } from "../fx/index.js";
  3import type { FetchCtx, FetchJsonCtx } from "../query/index.js";
  4import { isObject, noop } from "../query/util.js";
  5import type { Next } from "../types.js";
  6
  7/**
  8 * This middleware converts the name provided to {@link createApi}
  9 * into `url` and `method` for the fetch request.
 10 */
 11export function* nameParser<Ctx extends FetchJsonCtx = FetchJsonCtx>(
 12  ctx: Ctx,
 13  next: Next,
 14) {
 15  const httpMethods = [
 16    "get",
 17    "head",
 18    "post",
 19    "put",
 20    "delete",
 21    "connect",
 22    "options",
 23    "trace",
 24    "patch",
 25  ];
 26
 27  const options = ctx.payload || {};
 28  if (!isObject(options)) {
 29    yield* next();
 30    return;
 31  }
 32
 33  let url = Object.keys(options).reduce((acc, key) => {
 34    return acc.replace(`:${key}`, options[key]);
 35  }, ctx.name);
 36
 37  let method = "";
 38  httpMethods.forEach((curMethod) => {
 39    const pattern = new RegExp(`\\s*\\[${curMethod}\\]\\s*\\w*`, "i");
 40    const tmpUrl = url.replace(pattern, "");
 41    if (tmpUrl.length !== url.length) {
 42      method = curMethod.toLocaleUpperCase();
 43    }
 44    url = tmpUrl;
 45  }, url);
 46
 47  if (ctx.req().url === "") {
 48    ctx.request = ctx.req({ url });
 49  }
 50
 51  if (method) {
 52    ctx.request = ctx.req({ method });
 53  }
 54
 55  yield* next();
 56}
 57
 58/**
 59 * Automatically sets `content-type` to `application/json` when
 60 * that header is not already present.
 61 */
 62export function* headers<CurCtx extends FetchCtx = FetchCtx>(
 63  ctx: CurCtx,
 64  next: Next,
 65) {
 66  if (!ctx.request) {
 67    yield* next();
 68    return;
 69  }
 70
 71  const cur = ctx.req();
 72  if (!(cur as any).headers["Content-Type"]) {
 73    ctx.request = ctx.req({
 74      headers: { "Content-Type": "application/json" },
 75    });
 76  }
 77
 78  yield* next();
 79}
 80
 81/**
 82 * This middleware takes the `ctx.response` and sets `ctx.json` to the body representation
 83 * requested.  It uses the `ctx.bodyType` property to determine how to represent the body.
 84 * The default is set to `json` which calls `Response.json()`.
 85 *
 86 * @example
 87 * ```ts
 88 * const fetchUsers = api.get('/users', function*(ctx, next) {
 89 *  ctx.bodyType = 'text'; // calls Response.text();
 90 *  yield next();
 91 * })
 92 * ```
 93 */
 94export function* json<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
 95  ctx: CurCtx,
 96  next: Next,
 97) {
 98  if (!ctx.response) {
 99    yield* next();
100    return;
101  }
102
103  if (ctx.response.status === 204) {
104    ctx.json = {
105      ok: true,
106      value: {},
107    };
108    yield* next();
109    return;
110  }
111
112  const data = yield* safe(() => {
113    const resp = ctx.response;
114    if (!resp) throw new Error("response is falsy");
115    return resp[ctx.bodyType]();
116  });
117
118  if (data.ok) {
119    if (ctx.response.ok) {
120      ctx.json = {
121        ok: true,
122        value: data.value,
123      };
124    } else {
125      ctx.json = {
126        ok: false,
127        error: data.value,
128      };
129    }
130  } else {
131    const dta = { message: data.error.message };
132    ctx.json = {
133      ok: false,
134      error: dta,
135    };
136  }
137
138  yield* next();
139}
140
141/*
142 * This middleware takes the `baseUrl` provided to {@link mdw.fetch} and combines it
143 * with the url from `ctx.request.url`.
144 */
145export function composeUrl<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
146  baseUrl = "",
147) {
148  return function* (ctx: CurCtx, next: Next) {
149    const req = ctx.req();
150    ctx.request = ctx.req({ url: `${baseUrl}${req.url}` });
151    yield* next();
152  };
153}
154
155/**
156 * If there's a slug inside the ctx.name (which is the URL segement in this case)
157 * and there is *not* a corresponding truthy value in the payload, then that means
158 * the user has an empty value (e.g. empty string) which means we want to abort the
159 * fetch request.
160 *
161 * e.g. `ctx.name = "/apps/:id"` with `payload = { id: '' }`
162 *
163 * Ideally the action wouldn't have been dispatched at all but that is *not* a
164 * gaurantee we can make here.
165 */
166export function* payload<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
167  ctx: CurCtx,
168  next: Next,
169) {
170  const payload = ctx.payload;
171  if (!payload) {
172    yield* next();
173    return;
174  }
175
176  const keys = Object.keys(payload);
177  for (let i = 0; i < keys.length; i += 1) {
178    const key = keys[i];
179    if (!ctx.name.includes(`:${key}`)) {
180      continue;
181    }
182
183    const val = payload[key];
184    if (!val) {
185      const data = `found :${key} in endpoint name (${ctx.name}) but payload has falsy value (${val})`;
186      ctx.json = {
187        ok: false,
188        error: data,
189      };
190      return;
191    }
192  }
193
194  yield* next();
195}
196
197/*
198 * This middleware simply checks if `ctx.response` already contains a
199 * truthy value, and if it does, bail out of the middleware stack.
200 */
201export function response<CurCtx extends FetchCtx = FetchCtx>(
202  response?: Response,
203) {
204  return function* responseMdw(ctx: CurCtx, next: Next) {
205    if (response) {
206      ctx.response = response;
207    }
208    yield* next();
209  };
210}
211
212/*
213 * This middleware makes the `fetch` http request using `ctx.request` and
214 * assigns the response to `ctx.response`.
215 */
216export function* request<CurCtx extends FetchCtx = FetchCtx>(
217  ctx: CurCtx,
218  next: Next,
219) {
220  // if there is already a response then we want to bail so we don't
221  // override it.
222  if (ctx.response) {
223    yield* next();
224    return;
225  }
226
227  const { url, ...req } = ctx.req();
228  const request = new Request(url, req);
229  const result = yield* safe(fetch(request));
230  if (result.ok) {
231    ctx.response = result.value;
232  } else {
233    throw result.error;
234  }
235  yield* next();
236}
237
238function backoffExp(attempt: number): number {
239  if (attempt > 5) return -1;
240  // 1s, 1s, 1s, 2s, 4s
241  return Math.max(2 ** attempt * 125, 1000);
242}
243
244/**
245 * This middleware will retry failed `Fetch` request if `response.ok` is `false`.
246 * It accepts a backoff function to determine how long to continue retrying.
247 * The default is an exponential backoff {@link backoffExp} where the minimum is
248 * 1sec between attempts and it'll reach 4s between attempts at the end with a
249 * max of 5 attempts.
250 *
251 * An example backoff:
252 * @example
253 * ```ts
254 *  // Any value less than 0 will stop the retry middleware.
255 *  // Each attempt will wait 1s
256 *  const backoff = (attempt: number) => {
257 *    if (attempt > 5) return -1;
258 *    return 1000;
259 *  }
260 *
261 * const api = createApi();
262 * api.use(mdw.api());
263 * api.use(api.routes());
264 * api.use(mdw.fetch());
265 *
266 * const fetchUsers = api.get('/users', [
267 *  function*(ctx, next) {
268 *    // ...
269 *    yield next();
270 *  },
271 *  // fetchRetry should be after your endpoint function because
272 *  // the retry middleware will update `ctx.json` before it reaches
273 *  // your middleware
274 *  fetchRetry(backoff),
275 * ])
276 * ```
277 */
278export function fetchRetry<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
279  backoff: (attempt: number) => number = backoffExp,
280) {
281  return function* (ctx: CurCtx, next: Next) {
282    yield* next();
283
284    if (!ctx.response) {
285      return;
286    }
287
288    if (ctx.response.ok) {
289      return;
290    }
291
292    let attempt = 1;
293    let waitFor = backoff(attempt);
294    while (waitFor >= 1) {
295      yield* sleep(waitFor);
296      // reset response so `request` mdw actually runs
297      ctx.response = null;
298      yield* safe(() => request(ctx, noop));
299      yield* safe(() => json(ctx, noop));
300
301      if (ctx.response && (ctx.response as Response).ok) {
302        return;
303      }
304
305      attempt += 1;
306      waitFor = backoff(attempt);
307    }
308  };
309}