fetch-assets.ts 2.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  1. import picocolors from 'picocolors';
  2. import { defaultRequestInit, fetchWithRetry } from './fetch-retry';
  3. import { setTimeout } from 'node:timers/promises';
  4. // eslint-disable-next-line sukka/unicorn/custom-error-definition -- typescript is better
  5. export class CustomAbortError extends Error {
  6. public readonly name = 'AbortError';
  7. public readonly digest = 'AbortError';
  8. }
  9. export class Custom304NotModifiedError extends Error {
  10. public readonly name = 'Custom304NotModifiedError';
  11. public readonly digest = 'Custom304NotModifiedError';
  12. constructor(public readonly url: string) {
  13. super('304 Not Modified');
  14. }
  15. }
  16. export class CustomNoETagFallbackError extends Error {
  17. public readonly name = 'CustomNoETagFallbackError';
  18. public readonly digest = 'CustomNoETagFallbackError';
  19. constructor(public readonly data: string) {
  20. super('No ETag Fallback');
  21. }
  22. }
  23. export const sleepWithAbort = (ms: number, signal: AbortSignal) => new Promise<void>((resolve, reject) => {
  24. if (signal.aborted) {
  25. reject(signal.reason as Error);
  26. return;
  27. }
  28. signal.addEventListener('abort', stop, { once: true });
  29. // eslint-disable-next-line sukka/prefer-timer-id -- node:timers/promises
  30. setTimeout(ms, undefined, { ref: false }).then(resolve).catch(reject).finally(() => signal.removeEventListener('abort', stop));
  31. function stop(this: AbortSignal) { reject(this.reason as Error); }
  32. });
  33. export async function fetchAssets(url: string, fallbackUrls: string[] | readonly string[]) {
  34. const controller = new AbortController();
  35. const fetchMainPromise = fetchWithRetry(url, { signal: controller.signal, ...defaultRequestInit })
  36. .then(r => r.text())
  37. .then(text => {
  38. controller.abort();
  39. return text;
  40. });
  41. const createFetchFallbackPromise = async (url: string, index: number) => {
  42. // Most assets can be downloaded within 250ms. To avoid wasting bandwidth, we will wait for 500ms before downloading from the fallback URL.
  43. try {
  44. await sleepWithAbort(500 + (index + 1) * 10, controller.signal);
  45. } catch {
  46. console.log(picocolors.gray('[fetch cancelled early]'), picocolors.gray(url));
  47. throw new CustomAbortError();
  48. }
  49. if (controller.signal.aborted) {
  50. console.log(picocolors.gray('[fetch cancelled]'), picocolors.gray(url));
  51. throw new CustomAbortError();
  52. }
  53. const res = await fetchWithRetry(url, { signal: controller.signal, ...defaultRequestInit });
  54. const text = await res.text();
  55. controller.abort();
  56. return text;
  57. };
  58. return Promise.any([
  59. fetchMainPromise,
  60. ...fallbackUrls.map(createFetchFallbackPromise)
  61. ]).catch(e => {
  62. console.log(`Download Rule for [${url}] failed`);
  63. throw e;
  64. });
  65. }