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

ファイル システム標準では、ページのオリジンに固有のストレージ エンドポイントとしてオリジン専用ファイル システム(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);

メインスレッドまたはウェブワーカー

配信元の非公開ファイル システムを使用する方法には、メインスレッドで使用する方法と、ウェブ ワーカーで使用する方法の 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;
  };

ウェブワーカーで送信元のプライベート ファイル システムを使用する

前述のように、ウェブ ワーカーはメインスレッドをブロックできません。そのため、このコンテキストでは同期メソッドが許可されています。

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

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

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 によるヒーロー画像。