From 5db8d58b728979d68c359ea5da1dc15a355acfbc Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 18 May 2023 22:22:01 +0200 Subject: [PATCH] Add some protocol messages for wasm init --- drift/lib/src/web/wasm_setup.dart | 119 ++++++++++++++++++++++++++++++ drift/lib/wasm.dart | 67 ++++++++++++++++- drift/web/drift_worker.dart | 100 ++++++++++++++++++++++++- drift_dev/pubspec.yaml | 2 +- 4 files changed, 284 insertions(+), 4 deletions(-) diff --git a/drift/lib/src/web/wasm_setup.dart b/drift/lib/src/web/wasm_setup.dart index 9f47e429..40d1c020 100644 --- a/drift/lib/src/web/wasm_setup.dart +++ b/drift/lib/src/web/wasm_setup.dart @@ -1,9 +1,128 @@ +/// This file is responsible for opening a suitable WASM sqlite3 database based +/// on the features available in the browsing context we're in. +/// +/// The main challenge of hosting a sqlite3 database in the browser is the +/// implementation of a persistence solution. Being a C library, sqlite3 expects +/// synchronous access to a file system, which is tricky to implement with +/// asynchronous +library; + +import 'dart:html'; + import 'package:drift/wasm.dart'; +import 'package:js/js.dart'; +import 'package:js/js_util.dart'; +// ignore: implementation_imports +import 'package:sqlite3/src/wasm/js_interop/file_system_access.dart'; + +@JS() +@anonymous +class WorkerInitializationMessage { + external String get type; + external Object get payload; + + external factory WorkerInitializationMessage( + {required String type, required Object payload}); +} + +@JS() +@anonymous +class SharedWorkerSupportedFlags { + static const type = 'shared-supported'; + + external bool get canSpawnDedicatedWorkers; + external bool get dedicatedCanUseOpfs; + external bool get canUseIndexedDb; + + external factory SharedWorkerSupportedFlags({ + required bool canSpawnDedicatedWorkers, + required bool dedicatedCanUseOpfs, + required bool canUseIndexedDb, + }); +} + +@JS() +@anonymous +class DedicatedWorkerPurpose { + static const type = 'dedicated-worker-purpose'; + + static const purposeSharedOpfs = 'opfs-shared'; + + external String get purpose; + + external factory DedicatedWorkerPurpose({required String purpose}); +} + +@JS() +@anonymous +class WorkerSetupError { + static const type = 'worker-error'; +} + +@JS() +external bool get crossOriginIsolated; + +bool get supportsSharedWorkers => hasProperty(globalThis, 'SharedWorker'); Future openWasmDatabase({ required Uri sqlite3WasmUri, required Uri driftWorkerUri, required String databaseName, }) async { + // First, let's see if we can spawn dedicated workers in shared workers, which + // would enable us to efficiently share a OPFS database. + if (supportsSharedWorkers) { + final sharedWorker = + SharedWorker(driftWorkerUri.toString(), 'drift worker'); + final port = sharedWorker.port!; + } + throw 'todo'; } + +/// Checks whether the OPFS API is likely to be correctly implemented in the +/// current browser. +/// +/// Since OPFS uses the synchronous file system access API, this method can only +/// return true when called in a dedicated worker. +Future checkOpfsSupport() async { + final storage = storageManager; + if (storage == null) return false; + + final opfsRoot = await storage.directory; + const testFileName = '_drift_feature_detection'; + + FileSystemFileHandle? fileHandle; + FileSystemSyncAccessHandle? openedFile; + + try { + fileHandle = await opfsRoot.openFile(testFileName, create: true); + openedFile = await fileHandle.createSyncAccessHandle(); + + // In earlier versions of the OPFS standard, some methods like `getSize()` + // on a sync file handle have actually been asynchronous. We don't support + // Browsers that implement the outdated spec. + final getSizeResult = callMethod(openedFile, 'getSize', []); + if (typeofEquals(getSizeResult, 'object')) { + // Returned a promise, that's no good. + await promiseToFuture(getSizeResult!); + return false; + } + + return true; + } on Object { + return false; + } finally { + if (openedFile != null) { + openedFile.close(); + } + + if (fileHandle != null) { + await opfsRoot.removeEntry(testFileName); + } + } +} + +Future checkIndexedDbSupport() async { + return true; +} diff --git a/drift/lib/wasm.dart b/drift/lib/wasm.dart index a4a30950..531f3195 100644 --- a/drift/lib/wasm.dart +++ b/drift/lib/wasm.dart @@ -132,10 +132,75 @@ class _WasmDelegate extends Sqlite3Delegate { } } +/// The storage implementation used by the `drift` and `sqlite3` packages to +/// emulate a synchronous file system on the web, used by the sqlite3 C library +/// to store databases. +/// +/// As persistence APIs exposed by Browsers are usually asynchronous, faking +/// a synchronous file system enum WasmStorageImplementation { + /// Uses the [Origin private file system APIs][OPFS] provided my modern + /// browsers to persist data. + /// + /// In this storage mode, drift will host a single shared worker between tabs. + /// As the file system API is only available in dedicated workers, the shared + /// worker will spawn an internal dedicated worker which is thus shared between + /// tabs as well. + /// + /// The OPFS API allows synchronous access to files, but only after they have + /// been opened asynchronously. Since sqlite3 only needs access to two files + /// for a database files, we can just open them once and keep them open while + /// the database is in use. + /// + /// This mode is a very reliable and efficient approach to access sqlite3 + /// on the web, and the preferred mode used by drift. + /// + /// While the relevant specifications allow shared workers to spawn nested + /// workers, this is only implemented in Firefox at the time of writing. + /// Chrome (https://crbug.com/1088481) and Safari don't support this yet. + /// + /// [OPFS]: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API#origin_private_file_system opfsShared, + + /// Uses the [Origin private file system APIs][OPFS] provided my modern + /// browsers to persist data. + /// + /// Unlike [opfsShared], this storage implementation does not use a shared + /// worker (either because it is not available or because the current browser + /// doesn't allow shared workers to spawn dedicated workers). Instead, each + /// tab spawns two dedicated workers that use `Atomics.wait` and `Atomics.notify` + /// to turn the asynchronous OPFS API into a synchronous file system + /// implementation. + /// + /// While being less efficient than [opfsShared], this mode is also very + /// reliably and used by the official WASM builds of the sqlite3 project as + /// well. + /// + /// It requires [cross-origin isolation], which needs to be enabled by serving + /// your app with special headers: + /// + /// ``` + /// Cross-Origin-Opener-Policy: same-origin + /// Cross-Origin-Embedder-Policy: require-corp + /// ``` + /// + /// [OPFS]: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API#origin_private_file_system´ + /// [cross-origin isolation]: https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated opfsLocks, + + sharedIndexedDb, + + /// Uses the asynchronous IndexedDB API outside of any worker to persist data. + /// + /// Unlike [opfsShared] or [opfsLocks], this storage implementation can't + /// prevent two tabs from accessing the same data. unsafeIndexedDb, + + /// A fallback storage implementation that doesn't store anything. + /// + /// This implementation is chosen when none of the features needed for other + /// storage implementations are supported by the current browser. In this case, + /// [WasmDatabaseResult.missingFeatures] enumerates missing browser features. inMemory, } @@ -145,8 +210,8 @@ enum MissingBrowserFeature { nestedDedicatedWorkers, fileSystemAccess, indexedDb, - atomics, sharedArrayBuffers, + notCrossOriginIsolated, } class WasmDatabaseResult { diff --git a/drift/web/drift_worker.dart b/drift/web/drift_worker.dart index dcc8beb2..bbcb9123 100644 --- a/drift/web/drift_worker.dart +++ b/drift/web/drift_worker.dart @@ -1,3 +1,99 @@ -import 'package:drift/src/web/wasm_setup.dart'; +import 'dart:async'; +import 'dart:html'; -Future main() async {} +import 'package:drift/wasm.dart'; +import 'package:drift/src/web/wasm_setup.dart'; +import 'package:js/js_util.dart'; + +Future main() async { + final self = WorkerGlobalScope.instance; + + if (self is SharedWorkerGlobalScope) { + // This is a shared worker. It responds + _SharedDriftServer(self).start(); + } else {} +} + +class _SharedDriftServer { + final SharedWorkerGlobalScope self; + + /// If we end up using [WasmStorageImplementation.opfsShared], this is the + /// "shared-dedicated" worker hosting the database. + Worker? _dedicatedWorker; + Future? _featureDetection; + + _SharedDriftServer(this.self); + + void start() { + const event = EventStreamProvider('connect'); + event.forTarget(self).listen(_newConnection); + } + + void _newConnection(MessageEvent event) async { + // Start a feature detection run and inform the client about what we can do. + final detectionFuture = (_featureDetection ??= _startFeatureDetection()); + final responsePort = event.ports[0]; + + try { + final result = await detectionFuture; + responsePort.postMessage(WorkerInitializationMessage( + type: SharedWorkerSupportedFlags.type, payload: result)); + } catch (e) { + responsePort.postMessage(WorkerInitializationMessage( + type: WorkerSetupError.type, payload: WorkerSetupError())); + } + } + + Future _startFeatureDetection() async { + // First, let's see if this shared worker can spawn dedicated workers. + final hasWorker = hasProperty(self, 'Worker'); + final canUseIndexedDb = await checkIndexedDbSupport(); + + if (!hasWorker) { + return SharedWorkerSupportedFlags( + canSpawnDedicatedWorkers: false, + dedicatedCanUseOpfs: false, + canUseIndexedDb: canUseIndexedDb, + ); + } else { + final worker = _dedicatedWorker = Worker(Uri.base.toString()); + + // Tell the worker that we want to use it to host a shared OPFS database. + // It will respond with whether it can do that. + worker.postMessage(WorkerInitializationMessage( + type: DedicatedWorkerPurpose.type, + payload: DedicatedWorkerPurpose( + purpose: DedicatedWorkerPurpose.purposeSharedOpfs, + ), + )); + + final completer = Completer(); + StreamSubscription? messageSubscription, errorSubscription; + + void result(bool result) { + if (!completer.isCompleted) { + completer.complete(SharedWorkerSupportedFlags( + canSpawnDedicatedWorkers: true, + dedicatedCanUseOpfs: result, + canUseIndexedDb: canUseIndexedDb, + )); + + messageSubscription?.cancel(); + errorSubscription?.cancel(); + } + } + + messageSubscription = worker.onMessage.listen((event) { + result(event.data as bool); + }); + + errorSubscription = worker.onError.listen((event) { + result(false); + worker.terminate(); + _dedicatedWorker = null; + }); + + return completer.future; + } + } +} diff --git a/drift_dev/pubspec.yaml b/drift_dev/pubspec.yaml index 4a178559..91fd395f 100644 --- a/drift_dev/pubspec.yaml +++ b/drift_dev/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: # Drift-specific analysis and apis drift: '>=2.8.0 <2.9.0' - sqlite3: '>=0.1.6 <2.0.0' + sqlite3: '>=0.1.6 <3.0.0' sqlparser: '^0.30.0' # Dart analysis