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

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

ブラウザ サポート

オリジンの非公開ファイル システムは最新のブラウザでサポートされており、Web Hypertext Application Technology Working Group(WHATWG)の File System Living Standard で標準化されています。

対応ブラウザ

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

ソース

目的

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

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

ウェブ アプリケーションで To Do リストを編集する場合の一般的なフロー:

  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 は、事前に計算され、最適化された画像のシーケンスです。各画像は、前の画像よりも解像度が低く、ズームなどの多くの操作を高速化します。では、ウェブベースのファイル処理のパフォーマンス コストを負担することなく、ウェブ アプリケーションでファイルのメリットを享受するにはどうすればよいでしょうか。答えは、送信元のプライベート ファイル システムです。

ユーザーに表示されるファイル システムと送信元の非公開ファイル システム

オペレーティング システムのファイル エクスプローラを使用してブラウジングする、ユーザーに表示されるファイル システムとは異なり、読み取り、書き込み、移動、名前変更が可能なファイルとフォルダが存在するオリジンの非公開ファイル システムは、ユーザーに表示されることのないものです。送信元の非公開ファイル システム内のファイルとフォルダは、名前が示すように非公開です。具体的には、サイトの送信元に対して非公開です。DevTools コンソールに 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

オリジンのプライベート ファイル システムを使用する方法は 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.pushhandle.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 はメインスレッドをブロックできないため、このコンテキストでは同期メソッドが許可されます。

同期アクセス ハンドルの取得

可能な限り高速なファイル オペレーションのエントリ ポイントは 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] タブを選択します。これで、ファイル階層を検査できるようになります。ファイル名をクリックして、元の非公開ファイル システムからユーザーに表示されるファイル システムにファイルを保存します。ゴミ箱アイコンをクリックして、ファイルとフォルダを削除します。

デモ

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

まとめ

WHATWG で指定されている送信元の非公開ファイル システムは、ウェブ上でのファイルの使用方法と操作方法を形作ってきました。これにより、ユーザーに表示されるファイル システムでは実現できなかった新しいユースケースが可能になりました。すべての主要なブラウザ ベンダー(Apple、Mozilla、Google)が参加しており、共通のビジョンを共有しています。送信元の非公開ファイル システムの開発は、非常に協調的な取り組みであり、デベロッパーとユーザーからのフィードバックは進展に不可欠です。標準の改良を継続していくにあたり、whatwg/fs リポジトリに関するフィードバック(Issue または Pull Request の形で)をお待ちしております。

謝辞

この記事は、Austin SullyEtienne NoëlRachel Andrew が確認しました。UnsplashChristina Rumpf によるヒーロー画像。