fetch-assets.ts 2.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  1. import picocolors from 'picocolors';
  2. import { defaultRequestInit, fetchWithLog, ResponseError } 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, public readonly data: 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 function sleepWithAbort(ms: number, signal: AbortSignal) {
  24. return new Promise<void>((resolve, reject) => {
  25. if (signal.aborted) {
  26. reject(signal.reason as Error);
  27. return;
  28. }
  29. signal.addEventListener('abort', stop, { once: true });
  30. // eslint-disable-next-line sukka/prefer-timer-id -- node:timers/promises
  31. setTimeout(ms, undefined, { ref: false }).then(resolve).catch(reject).finally(() => signal.removeEventListener('abort', stop));
  32. function stop(this: AbortSignal) { reject(this.reason as Error); }
  33. });
  34. }
  35. export async function fetchAssetsWith304(url: string, fallbackUrls: string[] | readonly string[]) {
  36. const controller = new AbortController();
  37. const createFetchFallbackPromise = async (url: string, index: number) => {
  38. if (index > 0) {
  39. // Most assets can be downloaded within 250ms. To avoid wasting bandwidth, we will wait for 500ms before downloading from the fallback URL.
  40. try {
  41. await sleepWithAbort(500 + (index + 1) * 10, controller.signal);
  42. } catch {
  43. console.log(picocolors.gray('[fetch cancelled early]'), picocolors.gray(url));
  44. throw new CustomAbortError();
  45. }
  46. }
  47. if (controller.signal.aborted) {
  48. console.log(picocolors.gray('[fetch cancelled]'), picocolors.gray(url));
  49. throw new CustomAbortError();
  50. }
  51. const res = await fetchWithLog(url, { signal: controller.signal, ...defaultRequestInit });
  52. const text = await res.text();
  53. if (text.length < 2) {
  54. throw new ResponseError(res);
  55. }
  56. controller.abort();
  57. return text;
  58. };
  59. return Promise.any([
  60. createFetchFallbackPromise(url, -1),
  61. ...fallbackUrls.map(createFetchFallbackPromise)
  62. ]);
  63. }