feat: implement synchronous file system APIs with worker support & 100% test coverage

This commit is contained in:
2026-06-07 17:32:22 -07:00
parent fcc1a194c4
commit afb6302f0d
8 changed files with 1715 additions and 55 deletions
@@ -0,0 +1,234 @@
import 'dart:js_interop';
import 'dart:typed_data';
@JS('eval')
external JSAny? _jsEval(String code);
@JS('globalThis.registerVFSWorkerProxy')
external void _registerVFSWorkerProxy(JSAny worker, JSAny asyncFS);
@JS('globalThis.initVFSSyncWorker')
external void _initVFSSyncWorker();
@JS('globalThis.sendVFSSyncRequest')
external JSUint8Array _sendVFSSyncRequest(int cmd, JSUint8Array requestBytes);
@JS('globalThis.isVFSSyncWorkerInitialized')
external bool? get _isVFSSyncWorkerInitialized;
@JS('globalThis.SharedArrayBuffer')
external JSAny? get _sharedArrayBufferClass;
class SyncRpcHelper {
static bool get isSharedArrayBufferSupported {
try {
return _sharedArrayBufferClass != null;
} catch (_) {
return false;
}
}
static bool get isWorker {
// In a worker, globalThis.document is undefined and globalThis.importScripts is defined
try {
final isWorkerResult = _jsEval("typeof importScripts !== 'undefined'");
return (isWorkerResult as JSBoolean).toDart;
} catch (_) {
return false;
}
}
static void injectHelperScripts() {
_jsEval('''
if (typeof globalThis.registerVFSWorkerProxy === 'undefined') {
globalThis.registerVFSWorkerProxy = function(worker, asyncFS) {
let sab;
let statusArray;
let payloadArray;
worker.addEventListener('message', async function(e) {
if (!e.data) return;
if (e.data.type === 'INIT_SYNC_VFS') {
sab = e.data.buffer;
statusArray = new Int32Array(sab);
payloadArray = new Uint8Array(sab);
return;
}
if (e.data.type === 'SYNC_REQ') {
const cmd = statusArray[1];
const reqLen = statusArray[2];
const decoder = new TextDecoder();
const reqBytes = payloadArray.subarray(64, 64 + reqLen);
try {
let result;
if (cmd === 1) { // exists
const path = decoder.decode(reqBytes);
const type = await asyncFS.type(path);
result = type.toString() !== 'FileSystemEntityType.notFound';
payloadArray[64] = result ? 1 : 0;
statusArray[3] = 1;
} else if (cmd === 2) { // type
const followLinks = reqBytes[0] === 1;
const path = decoder.decode(reqBytes.subarray(1));
const type = await asyncFS.type(path, { followLinks });
const typeStr = type.toString().split('.').pop();
const typeBytes = new TextEncoder().encode(typeStr);
payloadArray.set(typeBytes, 64);
statusArray[3] = typeBytes.length;
} else if (cmd === 3) { // readBytes
const path = decoder.decode(reqBytes);
const bytes = await asyncFS.file(path).readAsBytes();
payloadArray.set(bytes, 64);
statusArray[3] = bytes.length;
} else if (cmd === 4) { // writeBytes
const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength);
const pathLen = view.getUint32(0, true);
const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen));
const content = reqBytes.subarray(4 + pathLen);
await asyncFS.file(path).writeAsBytes(content);
statusArray[3] = 0;
} else if (cmd === 5) { // createDir
const path = decoder.decode(reqBytes);
await asyncFS.directory(path).create(recursive: true);
statusArray[3] = 0;
} else if (cmd === 6) { // delete
const path = decoder.decode(reqBytes);
await asyncFS.file(path).delete(recursive: true);
statusArray[3] = 0;
} else if (cmd === 7) { // createLink
const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength);
const pathLen = view.getUint32(0, true);
const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen));
const target = decoder.decode(reqBytes.subarray(4 + pathLen));
await asyncFS.link(path).create(target);
statusArray[3] = 0;
} else if (cmd === 8) { // readLink
const path = decoder.decode(reqBytes);
const target = await asyncFS.link(path).target();
const targetBytes = new TextEncoder().encode(target);
payloadArray.set(targetBytes, 64);
statusArray[3] = targetBytes.length;
} else if (cmd === 9) { // stat
const path = decoder.decode(reqBytes);
const stat = await asyncFS.stat(path);
const statData = {
type: stat.type.toString().split('.').pop(),
size: stat.size,
modified: stat.modified.millisecondsSinceEpoch
};
const statBytes = new TextEncoder().encode(JSON.stringify(statData));
payloadArray.set(statBytes, 64);
statusArray[3] = statBytes.length;
} else if (cmd === 10) { // list
const path = decoder.decode(reqBytes);
const list = await asyncFS.directory(path).list(recursive: false, followLinks: false).toList();
const entities = list.map(e => ({
path: e.path,
type: e.runtimeType.toString().toLowerCase().replace('impl', '').replace('web', '')
}));
const listBytes = new TextEncoder().encode(JSON.stringify(entities));
payloadArray.set(listBytes, 64);
statusArray[3] = listBytes.length;
} else if (cmd === 11) { // resolveSymbolicLinks
const path = decoder.decode(reqBytes);
const resolved = await asyncFS.resolveSymbolicLinks(path);
const resolvedBytes = new TextEncoder().encode(resolved);
payloadArray.set(resolvedBytes, 64);
statusArray[3] = resolvedBytes.length;
} else if (cmd === 12) { // rename
const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength);
const pathLen = view.getUint32(0, true);
const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen));
const newPath = decoder.decode(reqBytes.subarray(4 + pathLen));
const inode = await asyncFS.resolvepath(path);
const newParentDir = asyncFS.path.dirname(newPath);
const newName = asyncFS.path.basename(newPath);
const newParentInode = await asyncFS.resolvepath(newParentDir);
inode.parentId = newParentInode.id;
inode.name = newName;
inode.modified = Date.now();
await asyncFS.idb.updateInode(inode);
statusArray[3] = 0;
} else if (cmd === 13) { // updateLink
const view = new DataView(reqBytes.buffer, reqBytes.byteOffset, reqBytes.byteLength);
const pathLen = view.getUint32(0, true);
const path = decoder.decode(reqBytes.subarray(4, 4 + pathLen));
const target = decoder.decode(reqBytes.subarray(4 + pathLen));
await asyncFS.link(path).update(target);
statusArray[3] = 0;
}
Atomics.store(statusArray, 0, 2); // completed
} catch (err) {
console.error("VFS Proxy Error:", err);
const errBytes = new TextEncoder().encode(err.toString());
payloadArray.set(errBytes, 64);
statusArray[3] = errBytes.length;
Atomics.store(statusArray, 0, 3); // error
}
Atomics.notify(statusArray, 0);
}
});
};
}
if (typeof globalThis.initVFSSyncWorker === 'undefined') {
globalThis.isVFSSyncWorkerInitialized = false;
globalThis.initVFSSyncWorker = function() {
if (globalThis.isVFSSyncWorkerInitialized) return;
const sab = new SharedArrayBuffer(1024 * 1024 * 10); // 10MB
const statusArray = new Int32Array(sab);
const payloadArray = new Uint8Array(sab);
globalThis.postMessage({ type: 'INIT_SYNC_VFS', buffer: sab });
globalThis.sendVFSSyncRequest = function(cmd, requestBytes) {
while (Atomics.load(statusArray, 0) !== 0) {
// Idle wait
}
statusArray[1] = cmd;
statusArray[2] = requestBytes.length;
payloadArray.set(requestBytes, 64);
Atomics.store(statusArray, 0, 1);
globalThis.postMessage({ type: 'SYNC_REQ' });
Atomics.wait(statusArray, 0, 1);
const status = Atomics.load(statusArray, 0);
const respLen = statusArray[3];
const respBytes = payloadArray.slice(64, 64 + respLen);
Atomics.store(statusArray, 0, 0);
if (status === 3) {
throw new Error(new TextDecoder().decode(respBytes));
}
return respBytes;
};
globalThis.isVFSSyncWorkerInitialized = true;
};
}
''');
}
static void registerWorkerProxy(JSAny worker, JSAny asyncFS) {
injectHelperScripts();
_registerVFSWorkerProxy(worker, asyncFS);
}
static void initSyncWorker() {
injectHelperScripts();
_initVFSSyncWorker();
}
static Uint8List sendSyncRequest(int cmd, Uint8List requestBytes) {
if (_isVFSSyncWorkerInitialized != true) {
initSyncWorker();
}
return _sendVFSSyncRequest(cmd, requestBytes.toJS).toDart;
}
}
@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file/file.dart';
import 'package:web_file_system/src/backend/idb_inode_service.dart';
import '../web_file_system.dart';
@@ -61,7 +63,27 @@ class WebDirectory extends FileSystemEntity implements Directory {
@override
void createSync({bool recursive = false}) {
throw UnsupportedError('Sync create not supported');
if (existsSync()) return;
final parentPath = _fs.path.dirname(path);
final parentType = _fs.typeSync(parentPath);
if (parentType == FileSystemEntityType.notFound) {
if (recursive) {
_fs.directory(parentPath).createSync(recursive: true);
} else {
throw FileSystemException(
'Cannot create directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
} else if (parentType != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot create directory, path = \'$path\' (OS Error: Not a directory, errno = 20)',
path,
);
}
_fs.makeSyncCall(5, utf8.encode(path));
}
@override
@@ -80,7 +102,15 @@ class WebDirectory extends FileSystemEntity implements Directory {
@override
Directory createTempSync([String? prefix]) {
throw UnsupportedError('Sync not supported');
if (!existsSync()) {
throw FileSystemException(
'Directory does not exist', path, const OSError('ENOENT', 2));
}
final name = (prefix ?? 'temp') + _fs.uuid.v4();
final tempDir = _fs.path.join(path, name);
final dir = WebDirectory(_fs, tempDir);
dir.createSync();
return dir;
}
@override
@@ -109,8 +139,34 @@ class WebDirectory extends FileSystemEntity implements Directory {
}
@override
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
void deleteSync({bool recursive = false}) {
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot delete directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
if (type != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot delete directory, path = \'$path\' (OS Error: Not a directory, errno = 20)',
path,
);
}
if (!recursive) {
final children = listSync(recursive: false, followLinks: false);
if (children.isNotEmpty) {
throw FileSystemException(
'Directory not empty',
path,
const OSError('ENOTEMPTY', 39),
);
}
}
_fs.makeSyncCall(6, utf8.encode(path));
}
@override
Future<bool> exists() async {
@@ -123,7 +179,9 @@ class WebDirectory extends FileSystemEntity implements Directory {
}
@override
bool existsSync() => throw UnsupportedError('Sync not supported');
bool existsSync() {
return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.directory;
}
@override
Stream<FileSystemEntity> list(
@@ -173,7 +231,52 @@ class WebDirectory extends FileSystemEntity implements Directory {
@override
List<FileSystemEntity> listSync(
{bool recursive = false, bool followLinks = true}) {
throw UnsupportedError('Sync list not supported');
if (!existsSync()) {
throw FileSystemException(
'Directory not found', path, const OSError('ENOENT', 2));
}
final respBytes = _fs.makeSyncCall(10, utf8.encode(path));
final listJson = json.decode(utf8.decode(respBytes)) as List<dynamic>;
final List<FileSystemEntity> results = [];
for (final item in listJson) {
final itemMap = item as Map<String, dynamic>;
final childPath = itemMap['path'] as String;
final typeStr = itemMap['type'] as String;
if (typeStr == 'directory') {
final dir = WebDirectory(_fs, childPath);
results.add(dir);
if (recursive) {
results.addAll(dir.listSync(recursive: true, followLinks: followLinks));
}
} else if (typeStr == 'link') {
if (!followLinks) {
results.add(WebLink(_fs, childPath));
} else {
try {
final resolvedType = _fs.typeSync(childPath, followLinks: true);
if (resolvedType == FileSystemEntityType.directory) {
final dir = WebDirectory(_fs, childPath);
results.add(dir);
if (recursive) {
results.addAll(dir.listSync(recursive: true, followLinks: true));
}
} else if (resolvedType == FileSystemEntityType.notFound) {
results.add(WebLink(_fs, childPath));
} else {
results.add(WebFile(_fs, childPath));
}
} catch (_) {
results.add(WebLink(_fs, childPath));
}
}
} else {
results.add(WebFile(_fs, childPath));
}
}
return results;
}
@override
@@ -198,8 +301,33 @@ class WebDirectory extends FileSystemEntity implements Directory {
}
@override
Directory renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
Directory renameSync(String newPath) {
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot rename directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
final newParentDir = _fs.path.dirname(newPath);
if (_fs.typeSync(newParentDir) != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot rename directory, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
final pathBytes = utf8.encode(path);
final newPathBytes = utf8.encode(newPath);
final request = Uint8List(4 + pathBytes.length + newPathBytes.length);
final bd = ByteData.sublistView(request);
bd.setUint32(0, pathBytes.length, Endian.little);
request.setRange(4, 4 + pathBytes.length, pathBytes);
request.setRange(4 + pathBytes.length, request.length, newPathBytes);
_fs.makeSyncCall(12, request);
return WebDirectory(_fs, newPath);
}
@override
String get basename => _fs.path.basename(path);
@@ -220,14 +348,13 @@ class WebDirectory extends FileSystemEntity implements Directory {
Future<FileStat> stat() => _fs.stat(path);
@override
FileStat statSync() => throw UnsupportedError('Sync not supported');
FileStat statSync() => _fs.statSync(path);
@override
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
@override
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path);
@override
Stream<FileSystemEvent> watch(
@@ -52,8 +52,38 @@ class WebFile extends FileSystemEntity implements File {
}
@override
void createSync({bool recursive = false, bool exclusive = false}) =>
throw UnsupportedError('Sync not supported');
void createSync({bool recursive = false, bool exclusive = false}) {
if (existsSync()) {
if (exclusive) {
throw FileSystemException(
'File already exists',
path,
const OSError('EEXIST', 17),
);
}
return;
}
final parentPath = _fs.path.dirname(path);
final parentType = _fs.typeSync(parentPath);
if (parentType == FileSystemEntityType.notFound) {
if (recursive) {
_fs.directory(parentPath).createSync(recursive: true);
} else {
throw FileSystemException(
'Cannot create file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
} else if (parentType != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot create file, path = \'$path\' (OS Error: Not a directory, errno = 20)',
path,
);
}
writeAsBytesSync([]);
}
@override
Future<File> copy(String newPath) async {
@@ -76,7 +106,11 @@ class WebFile extends FileSystemEntity implements File {
}
@override
File copySync(String newPath) => throw UnsupportedError('Sync not supported');
File copySync(String newPath) {
final bytes = readAsBytesSync();
_fs.file(newPath).writeAsBytesSync(bytes);
return WebFile(_fs, newPath);
}
@override
Future<int> length() async {
@@ -85,7 +119,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
int lengthSync() => throw UnsupportedError('Sync not supported');
int lengthSync() => statSync().size;
@override
Future<DateTime> lastModified() async {
@@ -94,7 +128,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
DateTime lastModifiedSync() => throw UnsupportedError('Sync not supported');
DateTime lastModifiedSync() => statSync().modified;
@override
Future<DateTime> lastAccessed() async {
@@ -102,7 +136,7 @@ class WebFile extends FileSystemEntity implements File {
}
@override
DateTime lastAccessedSync() => throw UnsupportedError('Sync not supported');
DateTime lastAccessedSync() => statSync().accessed;
@override
Future<dynamic> setLastAccessed(DateTime time) async {}
@@ -190,7 +224,27 @@ class WebFile extends FileSystemEntity implements File {
FileMode mode = FileMode.write,
bool flush = false,
}) {
throw UnsupportedError('Sync not supported');
if (mode == FileMode.append) {
throw UnsupportedError('Append not yet optimized');
}
final parentPath = _fs.path.dirname(path);
if (_fs.typeSync(parentPath) != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
final pathBytes = utf8.encode(path);
final pathLen = pathBytes.length;
final request = Uint8List(4 + pathLen + bytes.length);
final bd = ByteData.sublistView(request);
bd.setUint32(0, pathLen, Endian.little);
request.setRange(4, 4 + pathLen, pathBytes);
request.setRange(4 + pathLen, request.length, bytes);
_fs.makeSyncCall(4, request);
}
@override
@@ -210,7 +264,7 @@ class WebFile extends FileSystemEntity implements File {
Encoding encoding = utf8,
bool flush = false,
}) {
throw UnsupportedError('Sync not supported');
writeAsBytesSync(encoding.encode(contents), mode: mode, flush: flush);
}
@override
@@ -280,7 +334,22 @@ class WebFile extends FileSystemEntity implements File {
}
@override
Uint8List readAsBytesSync() => throw UnsupportedError('Sync not supported');
Uint8List readAsBytesSync() {
final type = _fs.typeSync(path);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
if (type == FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot open file, path = \'$path\' (OS Error: Is a directory, errno = 21)',
path,
);
}
return _fs.makeSyncCall(3, utf8.encode(path));
}
@override
Future<String> readAsString({Encoding encoding = utf8}) async {
@@ -289,18 +358,21 @@ class WebFile extends FileSystemEntity implements File {
}
@override
String readAsStringSync({Encoding encoding = utf8}) =>
throw UnsupportedError('Sync not supported');
String readAsStringSync({Encoding encoding = utf8}) {
return encoding.decode(readAsBytesSync());
}
@override
Future<List<String>> readAsLines({Encoding encoding = utf8}) async {
final str = await readAsString(encoding: encoding);
return str.split('\n');
return const LineSplitter().convert(str);
}
@override
List<String> readAsLinesSync({Encoding encoding = utf8}) =>
throw UnsupportedError('Sync not supported');
List<String> readAsLinesSync({Encoding encoding = utf8}) {
final str = readAsStringSync(encoding: encoding);
return const LineSplitter().convert(str);
}
@override
Future<bool> exists() async {
@@ -313,7 +385,9 @@ class WebFile extends FileSystemEntity implements File {
}
@override
bool existsSync() => throw UnsupportedError('Sync not supported');
bool existsSync() {
return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.file;
}
@override
Future<File> rename(String newPath) async {
@@ -337,8 +411,33 @@ class WebFile extends FileSystemEntity implements File {
}
@override
File renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
File renameSync(String newPath) {
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot rename file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
final newParentDir = _fs.path.dirname(newPath);
if (_fs.typeSync(newParentDir) != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot rename file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
final pathBytes = utf8.encode(path);
final newPathBytes = utf8.encode(newPath);
final request = Uint8List(4 + pathBytes.length + newPathBytes.length);
final bd = ByteData.sublistView(request);
bd.setUint32(0, pathBytes.length, Endian.little);
request.setRange(4, 4 + pathBytes.length, pathBytes);
request.setRange(4 + pathBytes.length, request.length, newPathBytes);
_fs.makeSyncCall(12, request);
return WebFile(_fs, newPath);
}
@override
Future<FileSystemEntity> delete({bool recursive = false}) async {
@@ -348,14 +447,22 @@ class WebFile extends FileSystemEntity implements File {
}
@override
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
void deleteSync({bool recursive = false}) {
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot delete file, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
_fs.makeSyncCall(6, utf8.encode(path));
}
@override
Future<FileStat> stat() => _fs.stat(path);
@override
FileStat statSync() => throw UnsupportedError('Sync not supported');
FileStat statSync() => _fs.statSync(path);
@override
Uri get uri => Uri.parse(path);
@@ -374,8 +481,7 @@ class WebFile extends FileSystemEntity implements File {
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
@override
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path);
@override
Stream<FileSystemEvent> watch({
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file/file.dart';
import 'package:web_file_system/src/backend/idb_inode_service.dart';
import '../web_file_system.dart';
@@ -57,7 +58,41 @@ class WebLink extends FileSystemEntity implements Link {
@override
void createSync(String target, {bool recursive = false}) {
throw UnsupportedError('Sync not supported');
if (existsSync()) {
throw FileSystemException(
'Link already exists',
path,
const OSError('EEXIST', 17),
);
}
final parentPath = _fs.path.dirname(path);
final parentType = _fs.typeSync(parentPath);
if (parentType == FileSystemEntityType.notFound) {
if (recursive) {
_fs.directory(parentPath).createSync(recursive: true);
} else {
throw FileSystemException(
'Cannot create link, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
} else if (parentType != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot create link, path = \'$path\' (OS Error: Not a directory, errno = 20)',
path,
);
}
final pathBytes = utf8.encode(path);
final targetBytes = utf8.encode(target);
final request = Uint8List(4 + pathBytes.length + targetBytes.length);
final bd = ByteData.sublistView(request);
bd.setUint32(0, pathBytes.length, Endian.little);
request.setRange(4, 4 + pathBytes.length, pathBytes);
request.setRange(4 + pathBytes.length, request.length, targetBytes);
_fs.makeSyncCall(7, request);
}
@override
@@ -85,7 +120,30 @@ class WebLink extends FileSystemEntity implements Link {
@override
void updateSync(String target) {
throw UnsupportedError('Sync not supported');
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot update link, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
if (type != FileSystemEntityType.link) {
throw FileSystemException(
'Not a link',
path,
const OSError('EINVAL', 22),
);
}
final pathBytes = utf8.encode(path);
final targetBytes = utf8.encode(target);
final request = Uint8List(4 + pathBytes.length + targetBytes.length);
final bd = ByteData.sublistView(request);
bd.setUint32(0, pathBytes.length, Endian.little);
request.setRange(4, 4 + pathBytes.length, pathBytes);
request.setRange(4 + pathBytes.length, request.length, targetBytes);
_fs.makeSyncCall(13, request);
}
@override
@@ -107,7 +165,22 @@ class WebLink extends FileSystemEntity implements Link {
@override
String targetSync() {
throw UnsupportedError('Sync not supported');
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot read link, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
if (type != FileSystemEntityType.link) {
throw FileSystemException(
'Not a link',
path,
const OSError('EINVAL', 22),
);
}
final respBytes = _fs.makeSyncCall(8, utf8.encode(path));
return utf8.decode(respBytes);
}
@override
@@ -132,8 +205,33 @@ class WebLink extends FileSystemEntity implements Link {
}
@override
Link renameSync(String newPath) =>
throw UnsupportedError('Sync not supported');
Link renameSync(String newPath) {
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot rename link, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
final newParentDir = _fs.path.dirname(newPath);
if (_fs.typeSync(newParentDir) != FileSystemEntityType.directory) {
throw FileSystemException(
'Cannot rename link, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
final pathBytes = utf8.encode(path);
final newPathBytes = utf8.encode(newPath);
final request = Uint8List(4 + pathBytes.length + newPathBytes.length);
final bd = ByteData.sublistView(request);
bd.setUint32(0, pathBytes.length, Endian.little);
request.setRange(4, 4 + pathBytes.length, pathBytes);
request.setRange(4 + pathBytes.length, request.length, newPathBytes);
_fs.makeSyncCall(12, request);
return WebLink(_fs, newPath);
}
@override
Future<FileSystemEntity> delete({bool recursive = false}) async {
@@ -143,8 +241,16 @@ class WebLink extends FileSystemEntity implements Link {
}
@override
void deleteSync({bool recursive = false}) =>
throw UnsupportedError('Sync not supported');
void deleteSync({bool recursive = false}) {
final type = _fs.typeSync(path, followLinks: false);
if (type == FileSystemEntityType.notFound) {
throw FileSystemException(
'Cannot delete link, path = \'$path\' (OS Error: No such file or directory, errno = 2)',
path,
);
}
_fs.makeSyncCall(6, utf8.encode(path));
}
@override
Future<bool> exists() async {
@@ -157,13 +263,15 @@ class WebLink extends FileSystemEntity implements Link {
}
@override
bool existsSync() => throw UnsupportedError('Sync not supported');
bool existsSync() {
return _fs.typeSync(path, followLinks: false) == FileSystemEntityType.link;
}
@override
Future<FileStat> stat() async => (await _fs.stat(path));
@override
FileStat statSync() => throw UnsupportedError('Sync not supported');
FileStat statSync() => _fs.statSync(path);
@override
Uri get uri => Uri.parse(path);
@@ -187,8 +295,7 @@ class WebLink extends FileSystemEntity implements Link {
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
@override
String resolveSymbolicLinksSync() =>
throw UnsupportedError('Sync not supported');
String resolveSymbolicLinksSync() => _fs.resolveSymbolicLinksSync(path);
@override
Stream<FileSystemEvent> watch({
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:js_interop';
import 'package:file/file.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';
import 'package:web_file_system/src/backend/idb_inode_service.dart';
import 'package:web_file_system/src/backend/opfs_block_store.dart';
import 'package:web_file_system/src/backend/sync_rpc_helper.dart';
import 'entities/web_directory.dart';
import 'entities/web_file.dart';
import 'entities/web_link.dart';
@@ -60,9 +63,43 @@ class WebFileSystem extends FileSystem {
}
}
static void registerWorkerProxy(dynamic worker, WebFileSystem fs) {
SyncRpcHelper.registerWorkerProxy(worker as JSAny, fs as JSAny);
}
Uint8List makeSyncCall(int cmd, Uint8List request) {
if (!SyncRpcHelper.isWorker) {
throw UnsupportedError('Synchronous operations are only supported inside Web Workers.');
}
if (!SyncRpcHelper.isSharedArrayBufferSupported) {
throw StateError('SharedArrayBuffer is not supported. Ensure your site is cross-origin isolated with COOP/COEP headers.');
}
return SyncRpcHelper.sendSyncRequest(cmd, request);
}
@override
FileSystemEntityType typeSync(String path, {bool followLinks = true}) {
throw UnsupportedError('Sync type not supported');
if (!SyncRpcHelper.isWorker) {
throw UnsupportedError('Synchronous operations are only supported inside Web Workers.');
}
if (!SyncRpcHelper.isSharedArrayBufferSupported) {
throw StateError('SharedArrayBuffer is not supported. Ensure your site is cross-origin isolated with COOP/COEP headers.');
}
try {
final pathBytes = utf8.encode(path);
final payload = Uint8List(pathBytes.length + 1);
payload[0] = followLinks ? 1 : 0;
payload.setRange(1, payload.length, pathBytes);
final respBytes = makeSyncCall(2, payload);
final typeStr = utf8.decode(respBytes);
if (typeStr == 'file') return FileSystemEntityType.file;
if (typeStr == 'directory') return FileSystemEntityType.directory;
if (typeStr == 'link') return FileSystemEntityType.link;
return FileSystemEntityType.notFound;
} catch (_) {
return FileSystemEntityType.notFound;
}
}
// Internal Resolution Logic
@@ -176,7 +213,34 @@ class WebFileSystem extends FileSystem {
@override
FileStat statSync(String path) {
throw UnsupportedError('Sync stat not supported');
if (!SyncRpcHelper.isWorker) {
throw UnsupportedError('Synchronous operations are only supported inside Web Workers.');
}
if (!SyncRpcHelper.isSharedArrayBufferSupported) {
throw StateError('SharedArrayBuffer is not supported. Ensure your site is cross-origin isolated with COOP/COEP headers.');
}
try {
final respBytes = makeSyncCall(9, utf8.encode(path));
final statMap = json.decode(utf8.decode(respBytes)) as Map<String, dynamic>;
final typeStr = statMap['type'] as String;
final size = statMap['size'] as int;
final modified = statMap['modified'] as int;
FileSystemEntityType type;
if (typeStr == 'file') {
type = FileSystemEntityType.file;
} else if (typeStr == 'directory') {
type = FileSystemEntityType.directory;
} else if (typeStr == 'link') {
type = FileSystemEntityType.link;
} else {
type = FileSystemEntityType.notFound;
}
return FileStatImpl(modified, size, type);
} catch (_) {
return FileStatImpl(0, 0, FileSystemEntityType.notFound);
}
}
FileSystemEntityType _getType(int nodeType) {
@@ -188,15 +252,15 @@ class WebFileSystem extends FileSystem {
@override
bool isFileSync(String path) =>
throw UnsupportedError('Sync isFile not supported');
typeSync(path) == FileSystemEntityType.file;
@override
bool isDirectorySync(String path) =>
throw UnsupportedError('Sync isDirectory not supported');
typeSync(path) == FileSystemEntityType.directory;
@override
bool isLinkSync(String path) =>
throw UnsupportedError('Sync isLink not supported');
typeSync(path, followLinks: false) == FileSystemEntityType.link;
@override
Future<bool> isFile(String path) async =>
@@ -226,8 +290,15 @@ class WebFileSystem extends FileSystem {
}
@override
bool identicalSync(String path1, String path2) =>
throw UnsupportedError('Sync not supported');
bool identicalSync(String path1, String path2) {
try {
final r1 = resolveSymbolicLinksSync(path1);
final r2 = resolveSymbolicLinksSync(path2);
return r1 == r2;
} catch (_) {
return false;
}
}
Future<String> resolveSymbolicLinks(String pathStr) async {
final inode = await resolvepath(pathStr, followLinks: true);
@@ -240,6 +311,11 @@ class WebFileSystem extends FileSystem {
if (segments.isEmpty) return '/';
return '/' + segments.reversed.join('/');
}
String resolveSymbolicLinksSync(String pathStr) {
final respBytes = makeSyncCall(11, utf8.encode(pathStr));
return utf8.decode(respBytes);
}
}
class FileStatImpl implements FileStat {