11th March, 2026

Now, Self-hosted LiveSync has finally begun to be split into the Self-hosted LiveSync plugin for Obsidian, and a properly abstracted version of it.
This may not offer much benefit to Obsidian plugin users, or might even cause a slight inconvenience, but I believe it will certainly help improve testability and make the ecosystem better.
However, I do not see the point in putting something with little benefit into beta, so I am handling this on the alpha branch. I would actually preferred to create an R&D branch, but I was not keen on the ampersand, and I feel it will eventually become a proper beta anyway.

### Refactored

- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`.
- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`.
- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`.

### Internal API changes

- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported.

### R&D

- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing.
- We can see the code in `src/apps/webapp` for the browser version.
This commit is contained in:
vorotamoroz
2026-03-11 05:47:00 +01:00
parent 9cf630320c
commit 0dfd42259d
77 changed files with 2849 additions and 909 deletions
@@ -0,0 +1,34 @@
import type { UXFileInfoStub, UXFolderInfo } from "../../../lib/src/common/types";
import type { IConversionAdapter } from "../../../lib/src/serviceModules/adapters";
import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes";
/**
* Conversion adapter implementation for FileSystem API
*/
export class FSAPIConversionAdapter implements IConversionAdapter<FSAPIFile, FSAPIFolder> {
nativeFileToUXFileInfoStub(file: FSAPIFile): UXFileInfoStub {
const pathParts = file.path.split("/");
const name = pathParts[pathParts.length - 1] || file.handle.name;
return {
name: name,
path: file.path,
stat: file.stat,
isFolder: false,
};
}
nativeFolderToUXFolder(folder: FSAPIFolder): UXFolderInfo {
const pathParts = folder.path.split("/");
const name = pathParts[pathParts.length - 1] || folder.handle.name;
const parentPath = pathParts.slice(0, -1).join("/");
return {
name: name,
path: folder.path,
isFolder: true,
children: [],
parent: parentPath as any,
};
}
}
@@ -0,0 +1,214 @@
import type { FilePath, UXStat } from "../../../lib/src/common/types";
import type { IFileSystemAdapter } from "../../../lib/src/serviceModules/adapters";
import { FSAPIPathAdapter } from "./FSAPIPathAdapter";
import { FSAPITypeGuardAdapter } from "./FSAPITypeGuardAdapter";
import { FSAPIConversionAdapter } from "./FSAPIConversionAdapter";
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
/**
* Complete file system adapter implementation for FileSystem API
*/
export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSAPIFile, FSAPIFolder, FSAPIStat> {
readonly path: FSAPIPathAdapter;
readonly typeGuard: FSAPITypeGuardAdapter;
readonly conversion: FSAPIConversionAdapter;
readonly storage: FSAPIStorageAdapter;
readonly vault: FSAPIVaultAdapter;
private fileCache = new Map<string, FSAPIFile>();
private handleCache = new Map<string, FileSystemFileHandle>();
constructor(private rootHandle: FileSystemDirectoryHandle) {
this.path = new FSAPIPathAdapter();
this.typeGuard = new FSAPITypeGuardAdapter();
this.conversion = new FSAPIConversionAdapter();
this.storage = new FSAPIStorageAdapter(rootHandle);
this.vault = new FSAPIVaultAdapter(rootHandle);
}
private normalisePath(path: FilePath | string): string {
return this.path.normalisePath(path as string);
}
/**
* Get file handle for a given path
*/
private async getFileHandleByPath(p: FilePath | string): Promise<FileSystemFileHandle | null> {
const pathStr = p as string;
// Check cache first
const cached = this.handleCache.get(pathStr);
if (cached) return cached;
try {
const parts = pathStr.split("/").filter((part) => part !== "");
if (parts.length === 0) return null;
let currentHandle: FileSystemDirectoryHandle = this.rootHandle;
const fileName = parts[parts.length - 1];
// Navigate to the parent directory
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
}
const fileHandle = await currentHandle.getFileHandle(fileName);
this.handleCache.set(pathStr, fileHandle);
return fileHandle;
} catch {
return null;
}
}
async getAbstractFileByPath(p: FilePath | string): Promise<FSAPIFile | null> {
const pathStr = this.normalisePath(p);
const cached = this.fileCache.get(pathStr);
if (cached) {
return cached;
}
return await this.refreshFile(pathStr);
}
/**
*
*/
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
const pathStr = this.normalisePath(p);
const exact = await this.getAbstractFileByPath(pathStr);
if (exact) {
return exact;
}
// TODO: Refactor: Very, Very heavy.
const lowerPath = pathStr.toLowerCase();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
await this.scanDirectory();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
return null;
}
async getFiles(): Promise<FSAPIFile[]> {
if (this.fileCache.size === 0) {
await this.scanDirectory();
}
return Array.from(this.fileCache.values());
}
async statFromNative(file: FSAPIFile): Promise<UXStat> {
// Refresh stat from the file handle
try {
const fileObject = await file.handle.getFile();
return {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
};
} catch {
return file.stat;
}
}
async reconcileInternalFile(p: string): Promise<void> {
// No-op in webapp version
// This is used by Obsidian to sync internal file metadata
}
/**
* Refresh file cache for a specific path
*/
async refreshFile(p: string): Promise<FSAPIFile | null> {
const pathStr = this.normalisePath(p);
const handle = await this.getFileHandleByPath(pathStr);
if (!handle) {
this.fileCache.delete(pathStr);
this.handleCache.delete(pathStr);
return null;
}
const fileObject = await handle.getFile();
const file: FSAPIFile = {
path: pathStr as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: handle,
};
this.fileCache.set(pathStr, file);
this.handleCache.set(pathStr, handle);
return file;
}
/**
* Helper method to recursively scan directory and populate file cache
*/
async scanDirectory(relativePath: string = ""): Promise<void> {
return shareRunningResult("scanDirectory:" + relativePath, async () => {
try {
const parts = relativePath.split("/").filter((part) => part !== "");
let currentHandle = this.rootHandle;
for (const part of parts) {
currentHandle = await currentHandle.getDirectoryHandle(part);
}
// Use AsyncIterator instead of .values() for better compatibility
for await (const [name, entry] of (currentHandle as any).entries()) {
const entryPath = relativePath ? `${relativePath}/${name}` : name;
if (entry.kind === "directory") {
// Recursively scan subdirectories
await this.scanDirectory(entryPath);
} else if (entry.kind === "file") {
const fileHandle = entry as FileSystemFileHandle;
const fileObject = await fileHandle.getFile();
const file: FSAPIFile = {
path: entryPath as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: fileHandle,
};
this.fileCache.set(entryPath, file);
this.handleCache.set(entryPath, fileHandle);
}
}
} catch (error) {
console.error(`Error scanning directory ${relativePath}:`, error);
}
});
}
/**
* Clear all caches
*/
clearCache(): void {
this.fileCache.clear();
this.handleCache.clear();
}
}
@@ -0,0 +1,18 @@
import type { FilePath } from "../../../lib/src/common/types";
import type { IPathAdapter } from "../../../lib/src/serviceModules/adapters";
import type { FSAPIFile } from "./FSAPITypes";
/**
* Path adapter implementation for FileSystem API
*/
export class FSAPIPathAdapter implements IPathAdapter<FSAPIFile> {
getPath(file: string | FSAPIFile): FilePath {
return (typeof file === "string" ? file : file.path) as FilePath;
}
normalisePath(p: string): string {
// Normalize path separators to forward slashes (like Obsidian)
// Remove leading/trailing slashes
return p.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
}
@@ -0,0 +1,210 @@
import type { UXDataWriteOptions } from "../../../lib/src/common/types";
import type { IStorageAdapter } from "../../../lib/src/serviceModules/adapters";
import type { FSAPIStat } from "./FSAPITypes";
/**
* Storage adapter implementation for FileSystem API
*/
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
constructor(private rootHandle: FileSystemDirectoryHandle) {}
/**
* Resolve a path to directory and file handles
*/
private async resolvePath(p: string): Promise<{
dirHandle: FileSystemDirectoryHandle;
fileName: string;
} | null> {
try {
const parts = p.split("/").filter((part) => part !== "");
if (parts.length === 0) {
return null;
}
let currentHandle = this.rootHandle;
const fileName = parts[parts.length - 1];
// Navigate to the parent directory
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
}
return { dirHandle: currentHandle, fileName };
} catch {
return null;
}
}
/**
* Get file handle for a given path
*/
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
const resolved = await this.resolvePath(p);
if (!resolved) return null;
try {
return await resolved.dirHandle.getFileHandle(resolved.fileName);
} catch {
return null;
}
}
/**
* Get directory handle for a given path
*/
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
try {
const parts = p.split("/").filter((part) => part !== "");
if (parts.length === 0) {
return this.rootHandle;
}
let currentHandle = this.rootHandle;
for (const part of parts) {
currentHandle = await currentHandle.getDirectoryHandle(part);
}
return currentHandle;
} catch {
return null;
}
}
async exists(p: string): Promise<boolean> {
const fileHandle = await this.getFileHandle(p);
if (fileHandle) return true;
const dirHandle = await this.getDirectoryHandle(p);
return dirHandle !== null;
}
async trystat(p: string): Promise<FSAPIStat | null> {
// Try as file first
const fileHandle = await this.getFileHandle(p);
if (fileHandle) {
const file = await fileHandle.getFile();
return {
size: file.size,
mtime: file.lastModified,
ctime: file.lastModified,
type: "file",
};
}
// Try as directory
const dirHandle = await this.getDirectoryHandle(p);
if (dirHandle) {
return {
size: 0,
mtime: Date.now(),
ctime: Date.now(),
type: "folder",
};
}
return null;
}
async stat(p: string): Promise<FSAPIStat | null> {
return await this.trystat(p);
}
async mkdir(p: string): Promise<void> {
const parts = p.split("/").filter((part) => part !== "");
let currentHandle = this.rootHandle;
for (const part of parts) {
currentHandle = await currentHandle.getDirectoryHandle(part, { create: true });
}
}
async remove(p: string): Promise<void> {
const resolved = await this.resolvePath(p);
if (!resolved) return;
await resolved.dirHandle.removeEntry(resolved.fileName, { recursive: true });
}
async read(p: string): Promise<string> {
const fileHandle = await this.getFileHandle(p);
if (!fileHandle) {
throw new Error(`File not found: ${p}`);
}
const file = await fileHandle.getFile();
return await file.text();
}
async readBinary(p: string): Promise<ArrayBuffer> {
const fileHandle = await this.getFileHandle(p);
if (!fileHandle) {
throw new Error(`File not found: ${p}`);
}
const file = await fileHandle.getFile();
return await file.arrayBuffer();
}
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const resolved = await this.resolvePath(p);
if (!resolved) {
throw new Error(`Invalid path: ${p}`);
}
// Ensure parent directory exists
await this.mkdir(p.split("/").slice(0, -1).join("/"));
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const resolved = await this.resolvePath(p);
if (!resolved) {
throw new Error(`Invalid path: ${p}`);
}
// Ensure parent directory exists
await this.mkdir(p.split("/").slice(0, -1).join("/"));
const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
const existing = await this.exists(p);
if (existing) {
const currentContent = await this.read(p);
await this.write(p, currentContent + data, options);
} else {
await this.write(p, data, options);
}
}
async list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
const dirHandle = await this.getDirectoryHandle(basePath);
if (!dirHandle) {
return { files: [], folders: [] };
}
const files: string[] = [];
const folders: string[] = [];
// Use AsyncIterator instead of .values() for better compatibility
for await (const [name, entry] of (dirHandle as any).entries()) {
const entryPath = basePath ? `${basePath}/${name}` : name;
if (entry.kind === "directory") {
folders.push(entryPath);
} else if (entry.kind === "file") {
files.push(entryPath);
}
}
return { files, folders };
}
}
@@ -0,0 +1,17 @@
import type { ITypeGuardAdapter } from "../../../lib/src/serviceModules/adapters";
import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes";
/**
* Type guard adapter implementation for FileSystem API
*/
export class FSAPITypeGuardAdapter implements ITypeGuardAdapter<FSAPIFile, FSAPIFolder> {
isFile(file: any): file is FSAPIFile {
return (
file && typeof file === "object" && "path" in file && "stat" in file && "handle" in file && !file.isFolder
);
}
isFolder(item: any): item is FSAPIFolder {
return item && typeof item === "object" && "path" in item && item.isFolder === true && "handle" in item;
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { FilePath, UXStat } from "../../../lib/src/common/types";
/**
* FileSystem API file representation
*/
export type FSAPIFile = {
path: FilePath;
stat: UXStat;
handle: FileSystemFileHandle;
};
/**
* FileSystem API folder representation
*/
export type FSAPIFolder = {
path: FilePath;
isFolder: true;
handle: FileSystemDirectoryHandle;
};
/**
* FileSystem API stat type (compatible with UXStat)
*/
export type FSAPIStat = UXStat;
@@ -0,0 +1,123 @@
import type { FilePath, UXDataWriteOptions } from "../../../lib/src/common/types";
import type { IVaultAdapter } from "../../../lib/src/serviceModules/adapters";
import type { FSAPIFile, FSAPIFolder } from "./FSAPITypes";
/**
* Vault adapter implementation for FileSystem API
*/
export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
constructor(private rootHandle: FileSystemDirectoryHandle) {}
async read(file: FSAPIFile): Promise<string> {
const fileObject = await file.handle.getFile();
return await fileObject.text();
}
async cachedRead(file: FSAPIFile): Promise<string> {
// No caching in webapp version, just read directly
return await this.read(file);
}
async readBinary(file: FSAPIFile): Promise<ArrayBuffer> {
const fileObject = await file.handle.getFile();
return await fileObject.arrayBuffer();
}
async modify(file: FSAPIFile, data: string, options?: UXDataWriteOptions): Promise<void> {
const writable = await file.handle.createWritable();
await writable.write(data);
await writable.close();
}
async modifyBinary(file: FSAPIFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const writable = await file.handle.createWritable();
await writable.write(data);
await writable.close();
}
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];
// Navigate to parent directory, creating as needed
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
}
// Create the file
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
// Get file metadata
const fileObject = await fileHandle.getFile();
return {
path: p as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: fileHandle,
};
}
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];
// Navigate to parent directory, creating as needed
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
}
// Create the file
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
// Get file metadata
const fileObject = await fileHandle.getFile();
return {
path: p as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: fileHandle,
};
}
async delete(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
const parts = file.path.split("/").filter((part) => part !== "");
const name = parts[parts.length - 1];
// Navigate to parent directory
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
}
// Remove the entry
await currentHandle.removeEntry(name, { recursive: force });
}
async trash(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
// In webapp, trash is the same as delete (no recycle bin)
await this.delete(file, force);
}
trigger(name: string, ...data: any[]): any {
// No-op in webapp version (no event system yet)
return undefined;
}
}