Skip to content

Commit

Permalink
Simplify creation of web workers for drift
Browse files Browse the repository at this point in the history
  • Loading branch information
simolus3 committed Apr 10, 2023
1 parent cb32b34 commit b4b4e35
Show file tree
Hide file tree
Showing 26 changed files with 615 additions and 150 deletions.
11 changes: 11 additions & 0 deletions docs/lib/snippets/engines/new_connect.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:drift/drift.dart';
import 'package:drift/web/worker.dart';

class Approach1 {
// #docregion approach1
Future<DatabaseConnection> connectToWorker() async {
return await connectToDriftWorker('/database_worker.dart.js',
mode: DriftWorkerMode.dedicatedInShared);
}
// #enddocregion approach1
}
24 changes: 24 additions & 0 deletions docs/lib/snippets/engines/new_worker.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'package:drift/drift.dart';
import 'package:drift/wasm.dart';
import 'package:drift/web/worker.dart';
import 'package:sqlite3/wasm.dart';

void main() {
driftWorkerMain(() {
return LazyDatabase(() async {
// You can use a different OPFS path here is you need more than one
// persisted database in your app.
final fileSystem = await OpfsFileSystem.loadFromStorage('my_database');

final sqlite3 = await WasmSqlite3.loadFromUrl(
// Uri where you're hosting the wasm bundle for sqlite3
Uri.parse('/sqlite3.wasm'),
environment: SqliteEnvironment(fileSystem: fileSystem),
);

// The path here should always be `database` since that is the only file
// persisted by the OPFS file system.
return WasmDatabase(sqlite3: sqlite3, path: 'database');
});
});
}
9 changes: 3 additions & 6 deletions docs/lib/snippets/engines/web_wasm.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'package:drift/drift.dart';
import 'package:drift/wasm.dart';
import 'package:http/http.dart' as http;
import 'package:sqlite3/wasm.dart';

QueryExecutor connect() {
Expand All @@ -9,11 +8,9 @@ QueryExecutor connect() {
// IndexedDB database (named `my_app` here).
final fs = await IndexedDbFileSystem.open(dbName: 'my_app');

// Load wasm bundle for sqlite3
final response = await http.get(Uri.parse('sqlite3.wasm'));
final sqlite3 = await WasmSqlite3.load(
response.bodyBytes,
SqliteEnvironment(fileSystem: fs),
final sqlite3 = await WasmSqlite3.loadFromUrl(
Uri.parse('sqlite3.wasm'),
environment: SqliteEnvironment(fileSystem: fs),
);

// Then, open a database:
Expand Down
28 changes: 28 additions & 0 deletions docs/lib/snippets/engines/workers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// #docregion worker
import 'dart:html';

import 'package:drift/drift.dart';
import 'package:drift/web.dart';
import 'package:drift/web/worker.dart';

void main() {
// Load sql.js library in the worker
WorkerGlobalScope.instance.importScripts('sql-wasm.js');

// Call drift function that will set up this worker
driftWorkerMain(() {
return WebDatabase.withStorage(DriftWebStorage.indexedDb('worker',
migrateFromLocalStorage: false, inWebWorker: true));
});
}
// #enddocregion worker

// #docregion client
DatabaseConnection connectToWorker() {
return DatabaseConnection.delayed(connectToDriftWorker(
'worker.dart.js',
// Note that SharedWorkers may not be available on all browsers and platforms.
mode: DriftWorkerMode.shared,
));
}
// #enddocregion client
38 changes: 5 additions & 33 deletions docs/pages/docs/Other engines/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,49 +150,21 @@ the regular implementation.

The following example is meant to be used with a regular Dart web app, compiled using
[build_web_compilers](https://pub.dev/packages/build_web_compilers).
A Flutter port of this example is [part of the drift repository](https://github.com/simolus3/drift/tree/develop/examples/flutter_web_worker_example).

To write a web worker that will serve requests for drift, create a file called `worker.dart` in
To write a web worker that will serve requests for drift, create a file called `worker.dart` in
the `web/` folder of your app. It could have the following content:

```dart
import 'dart:html';
import 'package:drift/drift.dart';
import 'package:drift/web.dart';
import 'package:drift/remote.dart';
void main() {
final self = SharedWorkerGlobalScope.instance;
self.importScripts('sql-wasm.js');
{% assign workers = 'package:drift_docs/snippets/engines/workers.dart.excerpt.json' | readString | json_decode %}

final db = WebDatabase.withStorage(DriftWebStorage.indexedDb('worker',
migrateFromLocalStorage: false, inWebWorker: true));
final server = DriftServer(DatabaseConnection(db));
self.onConnect.listen((event) {
final msg = event as MessageEvent;
server.serve(msg.ports.first.channel());
});
}
```
{% include "blocks/snippet" snippets = workers name = "worker" %}

For more information on this api, see the [remote API](https://pub.dev/documentation/drift/latest/remote/remote-library.html).

Connecting to that worker is very simple with drift's web and remote apis. In your regular app code (outside of the worker),
you can connect like this:

```dart
import 'dart:html';
import 'package:drift/remote.dart';
import 'package:drift/web.dart';
import 'package:web_worker_example/database.dart';
DatabaseConnection connectToWorker() {
final worker = SharedWorker('worker.dart.js');
return remote(worker.port!.channel());
}
```
{% include "blocks/snippet" snippets = workers name = "client" %}

You can then open a drift database with that connection.
For more information on the `DatabaseConnection` class, see the documentation on
Expand Down
157 changes: 157 additions & 0 deletions docs/pages/docs/Other engines/web2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
---
data:
title: Web draft
description: Draft for upcoming stable Drift web support.
hidden: true
template: layouts/docs/single
---

This draft document describes different approaches allowing drift to run on the
web.
After community feedback, this restructured page will replace the [existing web documentation]({{ 'web.md' | pageUrl }}).

## Introduction

Drift first gained its initial web support in 2019 by wrapping the sql.js JavaScript library.
This implementation, which is still supported today, relies on keeping an in-memory database that is periodically saved to local storage.
In the last years, development in web browsers and the Dart ecosystem enabled more performant approaches that are
unfortunately impossible to implement with the original drift web API.
This is the reason the original API is still considered experimental - while it will continue to be supported, it is now obvious
that there are better approaches coming up.

This page describes the fundamental challenges and required browser features used to efficiently run drift on the web.
It presents a guide on the current and most reliable approach to bring sqlite3 to the web, but older implementations
and approaches to migrate between them are still supported and documented as well.

## Setup

The recommended solution to run drift on the web is to use

- The File System Access API with an Origin-private File System (OPFS) for storing data, and
- shared web workers to share the database between multiple tabs.

Drift and the `sqlite3` Dart package provide helpers to use those OPFS and shared web workers
easily.
However, even though both web APIs are suppported in most browsers, they are still relatively new and your app
should handle them not being available. Drift provides a feature-detection API which you can use to warn your
users if persistence is unavailable - see the caveats section for details.

{% block "blocks/alert" title="Caveats" color = "warning" %}
Most browsers support both APIs today, with two notable exceptions:

- Chrome on Android does not support shared web workers.
- The stable version of Safari currently implements an older verison of the File System Access Standard.
This has been fixed in Technology Preview builds.

The File System Access API, or other persistence APIs are sometimes disabled in private or incognito tabs too.
You need to consider different fallbacks that you may want to support:

- If the File System Access API is not available, you may want to fall back to a different persistence layer like IndexedDb, silently use an in-memory database
only or warn the user about these circumstances. Note that, even in modern browsers, persistence may be blocked in private/incognito tabs.
- If shared workers are not available, you can still safely use the database, but not if multiple tabs of your web app are opened.
You could use [Web Locks](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API) to detect whether another instance of your
database is currently open and inform the user about this.

The [Flutter app example](https://github.com/simolus3/drift/tree/develop/examples/app) which is part of the Drift repository implements all
of these fallbacks.
Snippets to detect these error conditions are provided on this website, but the integration with fallbacks or user-visible warnings depends
on the structure of your app in the end.
{% endblock %}

### Ressources

First, you'll need a version of sqlite3 that has been compiled to WASM and is ready to use Dart bindings for its IO work.
You can grab this `sqlite3.wasm` file from the [GitHub releases](https://github.com/simolus3/sqlite3.dart/releases) of the sqlite3 package,
or [compile it yourself](https://github.com/simolus3/sqlite3.dart/tree/main/sqlite3#compiling).
You can host this file on a CDN, or just put it in the `web/` folder of your Flutter app so that it is part of the final bundle.
It is important that your web server serves the file with `Content-Type: application/wasm`. Browsers will refuse to load it otherwise.

### Drift web worker

Since OPFS is only available in dedicated web workers, you need to define a worker responsible for hosting the database in its thread.
The main tab will connect to that worker to access the database with a communication protocol handled by drift.

In its `web/worker.dart` library, Drift provies a suitable entrypoint for both shared and dedicated web workers hosting a sqlite3
database. It takes a callback creating the actual database connection. Drift will be responsible for creating the worker in the
right configuration.
But since the worker depends on the way you set up the database, we can't ship a precompiled worker JavaScript file. You need to
write the worker yourself and compile it to JavaScript.

The worker's source could be put into `web/database_worker.dart` and have a structure like the following:

{% assign worker = 'package:drift_docs/snippets/engines/new_worker.dart.excerpt.json' | readString | json_decode %}

{% include "blocks/snippet" snippets = worker %}

Drift will detect whether the worker is running as a shared or as a dedicated worker and call the callback to open the
database at a suitable time.

How to compile the worker depends on your build setup:

1. With regular Dart web apps, you're likely using `build_web_compilers` with `build_runner` or `webdev` already.
This build system can compile workers too.
[This build configuration](https://github.com/simolus3/drift/blob/develop/examples/web_worker_example/build.yaml) shows
how to configure `build_web_compilers` to always compile a worker with `dart2js`.
2. With Flutter wep apps, you can either use `build_web_compilers` too (since you're already using `build_runner` for
drift), or compile the worker with `dart compile js`. When using `build_web_compilers`, explicitly enable `dart2js`
or run the build with `--release`.

Make sure to always use `dart2js` (and not `dartdevc`) to compile a web worker, since modules emitted by `dartdevc` are
not directly supported in web workers.

#### Worker mode

Depending on the storage implementation you use in your app, different worker topologies can be used.
when in doubt, `DriftWorkerMode.dedicatedInShared` is a good default.

1. If you don't need support for multiple tabs accessing the database at the same time,
you can use `DriftWorkerMode.dedicated` which does not spawn a shared web worker.
2. The File System Acccess API can only be accessed in dedicated workers, which is why `DriftWorkerMode.dedicatedInShared`
is used. If you use a different file system implementation (like one based on IndexedDB), `DriftWorkerMode.shared`
is sufficient.

| Dedicated | Shared | Dedicated in shared |
|-----------|--------|---------------------|
| ![](dedicated.png) | ![](shared.png) | ![](dedicated_in_shared.png) |
| Each tab uses its own worker with an independent database. | A single worker hosting the database is used across tabs | Like "shared", except that the shared worker forwards requests to a dedicated worker. |

### Using the database

To spawn and connect to such a web worker, drift provides the `connectToDriftWorker` method:

{% assign snippets = 'package:drift_docs/snippets/engines/new_connect.dart.excerpt.json' | readString | json_decode %}

{% include "blocks/snippet" snippets = snippets name = "approach1" %}

The returned `DatabaseConnection` can be passed to the constructor of a generated database class.

## Technology challenges

Drift wraps [sqlite3](https://sqlite.org/index.html), a popular relational database written as a C library.
On native platforms, we can use `dart:ffi` to efficiently bind to C libraries. This is what a `NativeDatabase` does internally,
it gives us efficient and synchronous access to sqlite3.
On the web, C libraries can be compiled to [WebAssembly](https://webassembly.org/), a native-like low-level language.
While C code can be compiled to WebAssembly, there is no builtin support for file IO which would be required for a database.
This functionality needs to be implemented in JavaScript (or, in our case, in Dart).

For a long time, the web platform lacked a suitable persistence solution that could be used to give sqlite3 access to the
file system:

- Local storage is synchronous, but can't efficiently store binary data. Further, we can't efficiently change a portion of the
data stored in local storage. A one byte write to a 10MB database file requires writing everything again.
- IndexedDb supports binary data and could be used to store chunks of a file in rows. However, it is asynchronous and sqlite3,
being a C library, expects a synchronous IO layer.
- Finally, the newer File System Access API supports synchronous access to app data _and_ synchronous writes.
However, it is only supported in web workers.
Further, a file in this API can only be opened by one JavaScript context at a time.

While we can support asynchronous persistence APIs by keeping an in-memory cache for synchronous reads and simply not awaiting
writes, the direct File System Access API is more promising due to its synchronous nature that doesn't require caching the entire database in memory.

In addition to the persistence problem, there is an issue of concurrency when a user opens multiple tabs of your web app.
Natively, locks in the file system allow sqlite3 to guarantee that multiple processes can access the same database without causing
conflicts. On the web, no synchronous lock API exists between tabs.

## Legacy approaches

### sql.js {#sqljs}
2 changes: 1 addition & 1 deletion docs/pages/docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ This table list all supported drift implementations and on which platforms they
| `SqfliteQueryExecutor` from `package:drift_sqflite` | Android, iOS | Uses platform channels, Flutter only, no isolate support, doesn't support `flutter test`. Formerly known as `moor_flutter` |
| `NativeDatabase` from `package:drift/native.dart` | Android, iOS, Windows, Linux, macOS | No further setup is required for Flutter users. For support outside of Flutter, or in `flutter test`, see the [desktop](#desktop) section below. Usage in a [isolate]({{ 'Advanced Features/isolates.md' | pageUrl }}) is recommended. Formerly known as `package:moor/ffi.dart`. |
| `WebDatabase` from `package:drift/web.dart` | Web | Works with or without Flutter. A bit of [additional setup]({{ 'Other engines/web.md' | pageUrl }}) is required. |
| `WasmDatabase` from `package:drift/web.dart` | Web | Potentially faster than a `WebDatabase`, but still experimental and not yet production ready. See [this]({{ 'Other engines/web.md#drift-wasm' | pageUrl }}) for details. |
| `WasmDatabase` from `package:drift/web.dart` | Web | Potentially faster than a `WebDatabase`, but still experimental and not yet production ready. See [this]({{ 'Other engines/web2.md' | pageUrl }}) for details. |

To support all platforms in a shared codebase, you only need to change how you open your database, all other usages can stay the same.
[This repository](https://github.com/simolus3/drift/tree/develop/examples/app) gives an example on how to do that with conditional imports.
Expand Down
4 changes: 2 additions & 2 deletions docs/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ dependencies:
version: ^0.2.2
code_snippets:
hosted: https://simonbinder.eu
version: ^0.0.11
version: ^0.0.12
# used in snippets
http: ^0.13.5
sqlite3: ^1.7.2
sqlite3: ^1.11.0
# Fake path_provider for snippets
path_provider:
path: assets/path_provider
Expand Down
Binary file added docs/web/docs/other-engines/web2/dedicated.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/web/docs/other-engines/web2/shared.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions drift/lib/src/web/channel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'dart:html';

import 'package:stream_channel/stream_channel.dart';

/// Extension to transform a raw [MessagePort] from web workers into a Dart
/// [StreamChannel].
extension PortToChannel on MessagePort {
/// Converts this port to a two-way communication channel, exposed as a
/// [StreamChannel].
///
/// This can be used to implement a remote database connection over service
/// workers.
StreamChannel<Object?> channel() {
final controller = StreamChannelController();
onMessage.map((event) => event.data).pipe(controller.local.sink);
controller.local.stream.listen(postMessage, onDone: close);

return controller.foreign;
}
}
21 changes: 1 addition & 20 deletions drift/lib/web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,9 @@
@experimental
library drift.web;

import 'dart:html';

import 'package:meta/meta.dart';
import 'package:stream_channel/stream_channel.dart';

export 'src/web/sql_js.dart';
export 'src/web/storage.dart' hide CustomSchemaVersionSave;
export 'src/web/web_db.dart';

/// Extension to transform a raw [MessagePort] from web workers into a Dart
/// [StreamChannel].
extension PortToChannel on MessagePort {
/// Converts this port to a two-way communication channel, exposed as a
/// [StreamChannel].
///
/// This can be used to implement a remote database connection over service
/// workers.
StreamChannel<Object?> channel() {
final controller = StreamChannelController();
onMessage.map((event) => event.data).pipe(controller.local.sink);
controller.local.stream.listen(postMessage, onDone: close);

return controller.foreign;
}
}
export 'src/web/channel.dart';
Loading

0 comments on commit b4b4e35

Please sign in to comment.