Skip to content

fetch-url-to-file #1092

@ryota-murakami

Description

@ryota-murakami

npm パッケージにするなら、プロジェクト固有の依存(useEffectOnAnyUPLOAD_TYPE_NORMALPreparedUploadFileInfo など)をすべて剥がして、純粋に「URLからFileオブジェクトを得る」という関心だけに絞る必要があります。

パッケージ名のイメージ

fetch-url-to-file あたりが素直です。

エクスポートするもの

1. 純粋な非同期関数(コア)

React に依存しない関数で、Node.js やバニラJSからも使えるようにします。

// fetchUrlToFile(url, options?) => Promise<File>

export type FetchUrlToFileOptions = {
  /** File オブジェクトに付けるファイル名(デフォルト: URL末尾から推定) */
  fileName?: string;
  /** MIME type を明示指定(デフォルト: レスポンスの Content-Type) */
  mimeType?: string;
  /** fetch に渡す追加オプション(headers, credentials など) */
  fetchInit?: RequestInit;
  /** 中断用 AbortSignal */
  signal?: AbortSignal;
};

export async function fetchUrlToFile(
  url: string,
  options?: FetchUrlToFileOptions
): Promise<File> {
  const { fileName, mimeType, fetchInit, signal } = options ?? {};

  const response = await fetch(url, { ...fetchInit, signal });
  if (!response.ok) {
    throw new FetchUrlToFileError(
      `HTTP ${response.status} ${response.statusText}`,
      response.status
    );
  }

  const blob = await response.blob();

  const resolvedName =
    fileName ?? extractFileNameFromUrl(url) ?? 'downloaded-file';
  const resolvedType = mimeType ?? blob.type ?? 'application/octet-stream';

  return new File([blob], resolvedName, { type: resolvedType });
}

2. カスタムエラークラス

呼び出し側が HTTP ステータスでハンドリングできるようにします。

export class FetchUrlToFileError extends Error {
  constructor(
    message: string,
    public readonly status: number
  ) {
    super(message);
    this.name = 'FetchUrlToFileError';
  }
}

3. React フック(サブパスエクスポート)

React を使うプロジェクト向けに /react エントリで提供します。

// fetch-url-to-file/react
import { useEffect, useReducer, useCallback } from 'react';
import { fetchUrlToFile, type FetchUrlToFileOptions } from 'fetch-url-to-file';

type Status = 'idle' | 'loading' | 'success' | 'error';

type State = {
  status: Status;
  file: File | undefined;
  error: Error | undefined;
};

type UseFetchUrlToFileParams = FetchUrlToFileOptions & {
  url: string | null | undefined;
  enabled?: boolean; // デフォルト true
};

type UseFetchUrlToFileReturn = State & {
  refetch: () => void;
};

export function useFetchUrlToFile(
  params: UseFetchUrlToFileParams
): UseFetchUrlToFileReturn {
  const { url, enabled = true, ...options } = params;
  const [state, dispatch] = useReducer(reducer, initialState);

  const execute = useCallback(() => {
    if (!url) return;
    const controller = new AbortController();

    dispatch({ type: 'loading' });

    fetchUrlToFile(url, { ...options, signal: controller.signal })
      .then((file) => dispatch({ type: 'success', file }))
      .catch((error) => {
        if (!controller.signal.aborted) {
          dispatch({ type: 'error', error });
        }
      });

    return () => controller.abort();
  }, [url, options.fileName, options.mimeType]);

  useEffect(() => {
    if (!enabled || !url) {
      dispatch({ type: 'reset' });
      return;
    }
    return execute();
  }, [enabled, execute]);

  return { ...state, refetch: execute };
}

package.json のエクスポート構成

{
  "name": "fetch-url-to-file",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./react": {
      "import": "./dist/react.mjs",
      "require": "./dist/react.cjs",
      "types": "./dist/react.d.ts"
    }
  },
  "peerDependencies": {
    "react": ">=17.0.0"           // ./react を使う場合のみ必要
  },
  "peerDependenciesMeta": {
    "react": { "optional": true }
  }
}

利用イメージ

// React なしで使う場合
import { fetchUrlToFile } from 'fetch-url-to-file';

const file = await fetchUrlToFile('https://example.com/doc.pdf', {
  fileName: 'report.pdf',
  fetchInit: { credentials: 'include' },
});
// React コンポーネントから使う場合
import { useFetchUrlToFile } from 'fetch-url-to-file/react';

const { status, file, error, refetch } = useFetchUrlToFile({
  url: documentFile.file_url,
  fileName: documentFile.name || 'file.pdf',
  mimeType: 'application/pdf',
  enabled: isOpen,
});

設計判断のまとめ

コア関数をReactから完全に分離しているのは、テストが jest + jsdom だけで書けるのと、サーバーサイド(Node 18+ の fetch)やWeb Worker からも使えるようにするためです。キャンセルは元コードの isCancelled フラグではなく標準の AbortController / AbortSignal に統一しています。これによりブラウザが fetch 自体を中断できるのでリソースも無駄になりません。フック側は enabled + refetch のパターンで TanStack Query に近い使用感にしているので、既存プロジェクトに馴染みやすいはずです。

ファイル名の自動推定(extractFileNameFromUrl)やリトライ、プログレスコールバックあたりは、必要に応じてオプションとして足していける構造になっています。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions