送信元のプライベート ファイル システム

ファイル システム標準では、ページのオリジンに固有で、ユーザーには表示されないストレージ エンドポイントとしてオリジン プライベート ファイル システム(OPFS)が導入されています。これは、パフォーマンス向けに高度に最適化された特別な種類のファイルへのアクセスをオプションで提供します。

ブラウザ サポート

オリジン プライベート ファイル システムは、最新のブラウザでサポートされており、ファイル システムのライブ標準で Web Hypertext Application Technology Working Group(WHATWG)によって標準化されています。

Browser Support

  • Chrome: 86.
  • Edge: 86.
  • Firefox: 111.
  • Safari: 15.2.

Source

目的

パソコン上のファイルといえば、ファイル階層を思い浮かべるでしょう。ファイルはフォルダに整理され、オペレーティング システムのファイル エクスプローラーで確認できます。たとえば、Windows の場合、Tom というユーザーの To Do リストは C:\Users\Tom\Documents\ToDo.txt に保存されている可能性があります。この例では、ToDo.txt はファイル名、UsersTomDocuments はフォルダ名です。Windows の `C:` は、ドライブのルート ディレクトリを表します。

ウェブ上のファイルを操作する従来の方法

ウェブ アプリケーションで ToDo リストを編集する一般的なフローは次のとおりです。

  1. ユーザーがファイルをサーバーにアップロードするか、<input type="file"> を使用してクライアントで開きます
  2. ユーザーが変更を行い、JavaScript を介してプログラムで click() した <a download="ToDo.txt> が挿入された結果のファイルをダウンロードします。
  3. フォルダを開くには、<input type="file" webkitdirectory> の特別な属性を使用します。この属性は、独自の名前が付いていますが、ほとんどのブラウザでサポートされています。

ウェブ上のファイルを操作する最新の方法

このフローは、ユーザーがファイルを編集する方法を反映したものではなく、ユーザーは入力ファイルのダウンロードされたコピーを取得することになります。そのため、File System Access API では、名前のとおりの処理を行う 3 つのピッカー メソッド(showOpenFilePicker()showSaveFilePicker()showDirectoryPicker())が導入されました。フローは次のように有効になります。

  1. showOpenFilePicker()ToDo.txt を開き、FileSystemFileHandle オブジェクトを取得します。
  2. FileSystemFileHandle オブジェクトから、ファイル ハンドルの getFile() メソッドを呼び出して File を取得します。
  3. ファイルを変更し、ハンドルで requestPermission({mode: 'readwrite'}) を呼び出します。
  4. ユーザーが権限リクエストを承認した場合は、変更を元のファイルに保存します。
  5. または、showSaveFilePicker() を呼び出して、ユーザーに新しいファイルを選択させます。(以前に開いたファイルを選択すると、その内容が上書きされます)。保存を繰り返す場合は、ファイル ハンドルを保持しておけば、ファイル保存ダイアログを再度表示する必要はありません。

ウェブ上のファイルを操作する際の制限事項

これらのメソッドでアクセスできるファイルとフォルダは、ユーザーが認識できるファイル システムに存在します。ウェブから保存されたファイル(特に実行可能ファイル)には、マーク オブ ザ ウェブが付けられます。これにより、危険な可能性のあるファイルが実行される前に、オペレーティング システムが追加の警告を表示できます。追加のセキュリティ機能として、ウェブから取得したファイルもセーフ ブラウジングで保護されます。この記事では、セーフ ブラウジングをクラウドベースのウイルス スキャンと捉えてください。File System Access API を使用してファイルにデータを書き込む場合、書き込みはインプレースではなく、一時ファイルを使用します。これらのセキュリティ チェックにすべて合格しない限り、ファイル自体は変更されません。この作業により、可能な限り改善が適用されているにもかかわらず(macOS の場合など)、ファイル オペレーションが比較的遅くなります。ただし、すべての write() 呼び出しは自己完結型であるため、内部的にはファイルを開き、指定されたオフセットにシークしてから、データを書き込みます。

処理の基盤としてのファイル

同時に、ファイルはデータを記録する優れた方法でもあります。たとえば、SQLite はデータベース全体を 1 つのファイルに保存します。別の例として、画像処理で使用される mipmap があります。mipmap は、事前に計算され最適化された一連の画像です。各画像は、前の画像の解像度を段階的に下げた表現になっています。これにより、ズームなどの多くの操作が高速化されます。では、ウェブ アプリケーションは、ウェブベースのファイル処理のパフォーマンス コストをかけずに、ファイルのメリットを享受するにはどうすればよいのでしょうか?答えは、オリジン プライベート ファイル システムです。

ユーザーに表示されるファイル システムとオリジン プライベート ファイル システム

オペレーティング システムのファイル エクスプローラを使用してブラウジングするユーザーに表示されるファイル システムとは異なり、ファイルやフォルダの読み取り、書き込み、移動、名前の変更が可能なオリジン プライベート ファイル システムは、ユーザーに表示されることを想定していません。オリジン プライベート ファイル システム内のファイルとフォルダは、名前のとおりプライベートです。具体的には、サイトのオリジンに対してプライベートです。デベロッパー ツールのコンソールに「location.origin」と入力して、ページのオリジンを確認します。たとえば、ページ https://developer.chrome.com/articles/ のオリジンは https://developer.chrome.com です(つまり、/articles の部分はオリジンの一部ではありません)。オリジンの理論について詳しくは、「same-site」と「same-origin」についてをご覧ください。同じオリジンを共有するすべてのページは、同じオリジンのプライベート ファイル システム データを確認できるため、https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/ は前の例と同じ詳細を確認できます。各オリジンには独自の独立したオリジン プライベート ファイル システムがあります。つまり、https://developer.chrome.com のオリジン プライベート ファイル システムは、たとえば https://web.dev のものとは完全に異なります。Windows では、ユーザーに表示されるファイル システムのルート ディレクトリは C:\\ です。オリジン プライベート ファイル システムの同等のものは、非同期メソッド navigator.storage.getDirectory() を呼び出すことでアクセスされる、オリジンごとの初期状態が空のルート ディレクトリです。ユーザーに表示されるファイル システムとオリジン プライベート ファイル システムの比較については、次の図をご覧ください。この図は、ルート ディレクトリを除いて、他のすべてが概念的に同じであることを示しています。ファイルとフォルダの階層があり、データとストレージのニーズに合わせて整理および配置できます。

ユーザーに表示されるファイル システムとオリジン プライベート ファイル システムの図。2 つのファイル階層の例を示しています。ユーザーに表示されるファイル システムのエントリ ポイントはシンボリック ハードディスクで、オリジン プライベート ファイル システムのエントリ ポイントはメソッド「navigator.storage.getDirectory」の呼び出しです。

オリジン プライベート ファイル システムの詳細

ブラウザの他のストレージ メカニズム(localStorageIndexedDB など)と同様に、オリジン プライベート ファイル システムにはブラウザの割り当て制限が適用されます。ユーザーがすべての閲覧データまたはすべてのサイトデータを削除すると、オリジン プライベート ファイル システムも削除されます。navigator.storage.estimate() を呼び出し、結果のレスポンス オブジェクトで usage エントリを確認して、アプリがすでに使用しているストレージの量を確認します。この量は usageDetails オブジェクトのストレージ メカニズムごとに分類されています。特に fileSystem エントリを確認します。オリジン プライベート ファイル システムはユーザーに表示されないため、権限のプロンプトやセーフ ブラウジングのチェックは行われません。

ルート ディレクトリへのアクセス権を取得する

ルート ディレクトリにアクセスするには、次のコマンドを実行します。結果として、空のディレクトリ ハンドル(具体的には FileSystemDirectoryHandle)が返されます。

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

メインスレッドまたは Web Worker

オリジン プライベート ファイル システムを使用する方法は、メインスレッドWeb Worker の 2 つがあります。Web Worker はメインスレッドをブロックできないため、このコンテキストでは API を同期的にできます。これは、一般的にメインスレッドでは許可されていないパターンです。同期 API は、Promise を処理する必要がないため、高速になる可能性があります。また、WebAssembly にコンパイルできる C などの言語では、ファイル オペレーションは通常同期です。

// This is synchronous C code.
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

可能な限り高速なファイル オペレーションが必要な場合や、WebAssembly を扱う場合は、Web Worker でオリジン プライベート ファイル システムを使用するまでスキップしてください。それ以外の場合は、読み進めてください。

メインスレッドでオリジン プライベート ファイル システムを使用する

新しいファイルとフォルダを作成する

ルートフォルダを作成したら、getFileHandle() メソッドと getDirectoryHandle() メソッドを使用して、それぞれファイルとフォルダを作成します。{create: true} を渡すと、ファイルまたはフォルダが存在しない場合は作成されます。新しく作成したディレクトリを開始点として使用して、これらの関数を呼び出すことで、ファイルの階層を構築します。

const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('my first nested folder', {create: true});

前のコードサンプルから生成されたファイル階層。

既存のファイルとフォルダにアクセスする

名前がわかっている場合は、getFileHandle() メソッドまたは getDirectoryHandle() メソッドを呼び出し、ファイルまたはフォルダの名前を渡すことで、以前に作成したファイルやフォルダにアクセスできます。

const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder');

読み取り用のファイル ハンドルに関連付けられたファイルを取得する

FileSystemFileHandle は、ファイル システム上のファイルを表します。関連付けられた File を取得するには、getFile() メソッドを使用します。File オブジェクトは Blob の一種であり、Blob が使用できるコンテキストであればどこでも使用できます。特に、FileReaderURL.createObjectURL()createImageBitmap()XMLHttpRequest.send()BlobsFiles の両方を受け入れます。FileSystemFileHandle から File を取得すると、データが「解放」され、アクセスしてユーザーに表示されるファイル システムで使用できるようになります。

const file = await fileHandle.getFile();
console.log(await file.text());

ストリーミングでファイルに書き込む

createWritable() を呼び出してデータをファイルにストリーミングします。これにより、FileSystemWritableFileStream が作成され、そのコンテンツを write() します。最後に、ストリームを close() する必要があります。

const contents = 'Some text';
// Get a writable stream.
const writable = await fileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(contents);
// Close the stream, which persists the contents.
await writable.close();

ファイルとフォルダを削除する

ファイルまたはディレクトリ ハンドルの特定の remove() メソッドを呼び出して、ファイルとフォルダを削除します。すべてのサブフォルダを含むフォルダを削除するには、{recursive: true} オプションを渡します。

await fileHandle.remove();
await directoryHandle.remove({recursive: true});

別の方法として、ディレクトリ内の削除対象のファイルまたはフォルダの名前がわかっている場合は、removeEntry() メソッドを使用します。

directoryHandle.removeEntry('my first nested file');

ファイルやフォルダの移動と名前変更

move() メソッドを使用して、ファイルとフォルダの名前を変更して移動します。移動と名前の変更は同時に行うことも、個別に行うこともできます。

// Rename a file.
await fileHandle.move('my first renamed file');
// Move a file to another directory.
await fileHandle.move(nestedDirectoryHandle);
// Move a file to another directory and rename it.
await fileHandle
    .move(nestedDirectoryHandle, 'my first renamed and now nested file');

ファイルまたはフォルダのパスを解決する

特定のファイルまたはフォルダが参照ディレクトリに対してどこにあるかを確認するには、resolve() メソッドを使用し、引数として FileSystemHandle を渡します。オリジンのプライベート ファイル システム内のファイルまたはフォルダのフルパスを取得するには、navigator.storage.getDirectory() で取得した参照ディレクトリとしてルート ディレクトリを使用します。

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` is `['my first folder', 'my first nested folder']`.

2 つのファイルまたはフォルダ ハンドルが同じファイルまたはフォルダを指しているかどうかを確認する

2 つのハンドルがあり、同じファイルまたはフォルダを指しているかどうかわからないことがあります。これを確認するには、isSameEntry() メソッドを使用します。

fileHandle.isSameEntry(nestedFileHandle);
// Returns `false`.

フォルダの内容を一覧表示する

FileSystemDirectoryHandle は、for await…of ループで反復処理する非同期イテレータです。非同期イテレータとして、entries()values()keys() メソッドもサポートしています。必要な情報に応じて、これらのメソッドから選択できます。

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

フォルダとすべてのサブフォルダの内容を再帰的に一覧表示する

非同期ループと関数を再帰と組み合わせて処理すると、間違いが生じやすくなります。次の関数は、フォルダとそのすべてのサブフォルダの内容(すべてのファイルとそのサイズを含む)を一覧表示するための出発点として使用できます。ファイルサイズが不要な場合は、directoryEntryPromises.push の部分で handle.getFile() プロミスではなく handle を直接プッシュすることで、関数を簡略化できます。

  const getDirectoryEntriesRecursive = async (
    directoryHandle,
    relativePath = '.',
  ) => {
    const fileHandles = [];
    const directoryHandles = [];
    const entries = {};
    // Get an iterator of the files and folders in the directory.
    const directoryIterator = directoryHandle.values();
    const directoryEntryPromises = [];
    for await (const handle of directoryIterator) {
      const nestedPath = `${relativePath}/${handle.name}`;
      if (handle.kind === 'file') {
        fileHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          handle.getFile().then((file) => {
            return {
              name: handle.name,
              kind: handle.kind,
              size: file.size,
              type: file.type,
              lastModified: file.lastModified,
              relativePath: nestedPath,
              handle
            };
          }),
        );
      } else if (handle.kind === 'directory') {
        directoryHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          (async () => {
            return {
              name: handle.name,
              kind: handle.kind,
              relativePath: nestedPath,
              entries:
                  await getDirectoryEntriesRecursive(handle, nestedPath),
              handle,
            };
          })(),
        );
      }
    }
    const directoryEntries = await Promise.all(directoryEntryPromises);
    directoryEntries.forEach((directoryEntry) => {
      entries[directoryEntry.name] = directoryEntry;
    });
    return entries;
  };

Web Worker でオリジン プライベート ファイル システムを使用する

前述のように、Web Worker はメインスレッドをブロックできないため、このコンテキストでは同期メソッドが許可されています。

同期アクセス ハンドルを取得する

可能な限り高速なファイル オペレーションのエントリ ポイントは FileSystemSyncAccessHandle です。これは、createSyncAccessHandle() を呼び出すことで通常の FileSystemFileHandle から取得できます。

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

同期インプレース ファイル メソッド

同期アクセス ハンドルを取得すると、すべて同期の高速なインプレース ファイル メソッドにアクセスできます。

  • getSize(): ファイルのサイズをバイト単位で返します。
  • write(): バッファの内容をファイルに書き込みます(必要に応じて指定されたオフセットで)。書き込まれたバイト数を返します。返された書き込みバイト数を確認することで、呼び出し元はエラーと部分的な書き込みを検出して処理できます。
  • read(): ファイルの内容をバッファに読み取ります。必要に応じて、指定されたオフセットで読み取ります。
  • truncate(): ファイルを指定されたサイズに変更します。
  • flush(): ファイルの内容に write() を介して行われたすべての変更が含まれていることを確認します。
  • close(): アクセス ハンドルを閉じます。

上記のメソッドをすべて使用する例を次に示します。

const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle('fast', {create: true});
const accessHandle = await fileHandle.createSyncAccessHandle();

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

// Initialize this variable for the size of the file.
let size;
// The current size of the file, initially `0`.
size = accessHandle.getSize();
// Encode content to write to the file.
const content = textEncoder.encode('Some text');
// Write the content at the beginning of the file.
accessHandle.write(content, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `9` (the length of "Some text").
size = accessHandle.getSize();

// Encode more content to write to the file.
const moreContent = textEncoder.encode('More content');
// Write the content at the end of the file.
accessHandle.write(moreContent, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `21` (the length of
// "Some textMore content").
size = accessHandle.getSize();

// Prepare a data view of the length of the file.
const dataView = new DataView(new ArrayBuffer(size));

// Read the entire file into the data view.
accessHandle.read(dataView);
// Logs `"Some textMore content"`.
console.log(textDecoder.decode(dataView));

// Read starting at offset 9 into the data view.
accessHandle.read(dataView, {at: 9});
// Logs `"More content"`.
console.log(textDecoder.decode(dataView));

// Truncate the file after 4 bytes.
accessHandle.truncate(4);

オリジン プライベート ファイル システムからユーザーに表示されるファイル システムにファイルをコピーする

前述のように、元のプライベート ファイル システムからユーザーに表示されるファイル システムにファイルを移動することはできませんが、ファイルをコピーすることはできます。showSaveFilePicker() はメインスレッドでのみ公開され、ワーカー スレッドでは公開されないため、コードはメインスレッドで実行してください。

// On the main thread, not in the Worker. This assumes
// `fileHandle` is the `FileSystemFileHandle` you obtained
// the `FileSystemSyncAccessHandle` from in the Worker
// thread. Be sure to close the file in the Worker thread first.
const fileHandle = await opfsRoot.getFileHandle('fast');
try {
  // Obtain a file handle to a new file in the user-visible file system
  // with the same name as the file in the origin private file system.
  const saveHandle = await showSaveFilePicker({
    suggestedName: fileHandle.name || ''
  });
  const writable = await saveHandle.createWritable();
  await writable.write(await fileHandle.getFile());
  await writable.close();
} catch (err) {
  console.error(err.name, err.message);
}

オリジンのプライベート ファイル システムをデバッグする

組み込みの DevTools サポートが追加されるまでは(crbug/1284595 を参照)、OPFS Explorer Chrome 拡張機能を使用してオリジン プライベート ファイル システムをデバッグします。ちなみに、上記のスクリーンショットは、新しいファイルとフォルダの作成セクションの拡張機能から直接取得したものです。

Chrome ウェブストアの OPFS Explorer Chrome DevTools 拡張機能。

拡張機能をインストールしたら、Chrome DevTools を開き、[OPFS Explorer] タブを選択します。これで、ファイル階層を検査する準備が整いました。ファイル名をクリックして、元のプライベート ファイル システムからユーザーに表示されるファイル システムにファイルを保存します。ゴミ箱アイコンをクリックして、ファイルやフォルダを削除します。

デモ

デモでは、WebAssembly にコンパイルされた SQLite データベースのバックエンドとして使用されているオリジン プライベート ファイル システムの動作を確認できます(OPFS Explorer 拡張機能をインストールした場合)。Glitch のソースコードも必ずご確認ください。下の埋め込みバージョンでは、オリジン プライベート ファイル システム バックエンドが使用されていません(iframe がクロスオリジンであるため)。しかし、デモを別のタブで開くと、使用されます。

まとめ

WHATWG で指定されているオリジン プライベート ファイル システムは、ウェブ上のファイルの使用方法と操作方法を形作ってきました。これにより、ユーザーに表示されるファイル システムでは実現できなかった新しいユースケースが可能になりました。主要なブラウザ ベンダー(Apple、Mozilla、Google)はすべて参加しており、共通のビジョンを共有しています。オリジン プライベート ファイル システムの開発は共同作業であり、開発者とユーザーからのフィードバックが不可欠です。標準の改善と改良を継続していくにあたり、whatwg/fs リポジトリへの問題やプルリクエストの形式でのフィードバックをお待ちしています。

謝辞

この記事は、Austin SullyEtienne NoëlRachel Andrew によってレビューされました。ヒーロー画像: Christina RumpfUnsplash より)。