Work on #508 (thin client)

This commit is contained in:
Zef Hemel
2023-08-26 08:31:51 +02:00
parent 3af0f180cd
commit 9ee9008bf2
30 changed files with 717 additions and 327 deletions
+9 -5
View File
@@ -1,11 +1,13 @@
import { safeRun } from "../common/util.ts";
import { Client } from "./client.ts";
const thinClientMode = window.silverBulletConfig.thinClientMode === "on";
safeRun(async () => {
console.log("Booting SilverBullet...");
const client = new Client(
document.getElementById("sb-root")!,
thinClientMode,
);
await client.init();
window.client = client;
@@ -19,12 +21,14 @@ if (navigator.serviceWorker) {
.then(() => {
console.log("Service worker registered...");
});
navigator.serviceWorker.ready.then((registration) => {
registration.active!.postMessage({
type: "config",
config: window.silverBulletConfig,
if (!thinClientMode) {
navigator.serviceWorker.ready.then((registration) => {
registration.active!.postMessage({
type: "config",
config: window.silverBulletConfig,
});
});
});
}
} else {
console.warn(
"Not launching service worker, likely because not running from localhost or over HTTPs. This means SilverBullet will not be available offline.",
+44 -33
View File
@@ -36,6 +36,7 @@ import { MainUI } from "./editor_ui.tsx";
import { DexieMQ } from "../plugos/lib/mq.dexie.ts";
import { cleanPageRef } from "$sb/lib/resolve.ts";
import { expandPropertyNames } from "$sb/lib/json.ts";
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
const autoSaveInterval = 1000;
@@ -45,6 +46,7 @@ declare global {
// Injected via index.html
silverBulletConfig: {
spaceFolderPath: string;
thinClientMode: "on" | "off";
};
client: Client;
}
@@ -53,15 +55,13 @@ declare global {
// TODO: Oh my god, need to refactor this
export class Client {
system: ClientSystem;
editorView: EditorView;
private pageNavigator!: PathPageNavigator;
private dbPrefix: string;
plugSpaceRemotePrimitives!: PlugSpacePrimitives;
localSpacePrimitives!: FilteredSpacePrimitives;
// localSpacePrimitives!: FilteredSpacePrimitives;
remoteSpacePrimitives!: HttpSpacePrimitives;
space!: Space;
@@ -88,6 +88,7 @@ export class Client {
constructor(
parent: Element,
private thinClientMode = false,
) {
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
this.dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);
@@ -116,12 +117,13 @@ export class Client {
this.mq,
this.dbPrefix,
this.eventHook,
this.thinClientMode,
);
this.initSpace();
const localSpacePrimitives = this.initSpace();
this.syncService = new SyncService(
this.localSpacePrimitives,
localSpacePrimitives,
this.plugSpaceRemotePrimitives,
this.kvStore,
this.eventHook,
@@ -133,6 +135,7 @@ export class Client {
// Except federated ones
path.startsWith("!");
},
!this.thinClientMode,
);
this.ui = new MainUI(this);
@@ -319,7 +322,7 @@ export class Client {
}
}
initSpace() {
initSpace(): SpacePrimitives {
this.remoteSpacePrimitives = new HttpSpacePrimitives(
location.origin,
window.silverBulletConfig.spaceFolderPath,
@@ -332,34 +335,40 @@ export class Client {
let fileFilterFn: (s: string) => boolean = () => true;
this.localSpacePrimitives = new FilteredSpacePrimitives(
new FileMetaSpacePrimitives(
new EventedSpacePrimitives(
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
new FallbackSpacePrimitives(
new IndexedDBSpacePrimitives(
`${this.dbPrefix}_space`,
globalThis.indexedDB,
),
this.plugSpaceRemotePrimitives,
),
this.eventHook,
),
this.system.indexSyscalls,
),
(meta) => fileFilterFn(meta.name),
// Run when a list of files has been retrieved
async () => {
await this.loadSettings();
if (typeof this.settings?.spaceIgnore === "string") {
fileFilterFn = gitIgnoreCompiler(this.settings.spaceIgnore).accepts;
} else {
fileFilterFn = () => true;
}
},
);
let localSpacePrimitives: SpacePrimitives | undefined;
this.space = new Space(this.localSpacePrimitives, this.kvStore);
if (!this.thinClientMode) {
localSpacePrimitives = new FilteredSpacePrimitives(
new FileMetaSpacePrimitives(
new EventedSpacePrimitives(
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
new FallbackSpacePrimitives(
new IndexedDBSpacePrimitives(
`${this.dbPrefix}_space`,
globalThis.indexedDB,
),
this.plugSpaceRemotePrimitives,
),
this.eventHook,
),
this.system.indexSyscalls,
),
(meta) => fileFilterFn(meta.name),
// Run when a list of files has been retrieved
async () => {
await this.loadSettings();
if (typeof this.settings?.spaceIgnore === "string") {
fileFilterFn = gitIgnoreCompiler(this.settings.spaceIgnore).accepts;
} else {
fileFilterFn = () => true;
}
},
);
} else {
localSpacePrimitives = this.plugSpaceRemotePrimitives;
}
this.space = new Space(localSpacePrimitives, this.kvStore);
this.space.on({
pageChanged: (meta) => {
@@ -379,6 +388,8 @@ export class Client {
});
this.space.watch();
return localSpacePrimitives;
}
async loadSettings(): Promise<BuiltinSettings> {
+28 -20
View File
@@ -33,6 +33,8 @@ import {
import { DexieMQ } from "../plugos/lib/mq.dexie.ts";
import { MQHook } from "../plugos/hooks/mq.ts";
import { mqSyscalls } from "../plugos/syscalls/mq.dexie.ts";
import { indexProxySyscalls } from "./syscalls/index.proxy.ts";
import { storeProxySyscalls } from "./syscalls/store.proxy.ts";
export class ClientSystem {
system: System<SilverBulletHooks> = new System("client");
@@ -45,11 +47,12 @@ export class ClientSystem {
mdExtensions: MDExt[] = [];
constructor(
private editor: Client,
private client: Client,
private kvStore: DexieKVStore,
private mq: DexieMQ,
private dbPrefix: string,
private eventHook: EventHook,
private thinClientMode: boolean,
) {
this.system.addHook(this.eventHook);
@@ -61,11 +64,15 @@ export class ClientSystem {
const cronHook = new CronHook(this.system);
this.system.addHook(cronHook);
this.indexSyscalls = pageIndexSyscalls(
`${dbPrefix}_page_index`,
globalThis.indexedDB,
globalThis.IDBKeyRange,
);
if (thinClientMode) {
this.indexSyscalls = indexProxySyscalls(client);
} else {
this.indexSyscalls = pageIndexSyscalls(
`${dbPrefix}_page_index`,
globalThis.indexedDB,
globalThis.IDBKeyRange,
);
}
// Code widget hook
this.codeWidgetHook = new CodeWidgetHook();
@@ -78,7 +85,7 @@ export class ClientSystem {
this.commandHook = new CommandHook();
this.commandHook.on({
commandsUpdated: (commandMap) => {
this.editor.ui.viewDispatch({
this.client.ui.viewDispatch({
type: "update-commands",
commands: commandMap,
});
@@ -87,7 +94,7 @@ export class ClientSystem {
this.system.addHook(this.commandHook);
// Slash command hook
this.slashCommandHook = new SlashCommandHook(this.editor);
this.slashCommandHook = new SlashCommandHook(this.client);
this.system.addHook(this.slashCommandHook);
this.eventHook.addLocalListener("plug:changed", async (fileName) => {
@@ -96,7 +103,7 @@ export class ClientSystem {
const plug = await this.system.load(
new URL(`/${fileName}`, location.href),
createSandbox,
this.editor.settings.plugOverrides,
this.client.settings.plugOverrides,
);
if ((plug.manifest! as Manifest).syntax) {
// If there are syntax extensions, rebuild the markdown parser immediately
@@ -108,19 +115,21 @@ export class ClientSystem {
}
registerSyscalls() {
const storeCalls = storeSyscalls(this.kvStore);
const storeCalls = this.thinClientMode
? storeProxySyscalls(this.client)
: storeSyscalls(this.kvStore);
// Slash command hook
this.slashCommandHook = new SlashCommandHook(this.editor);
this.slashCommandHook = new SlashCommandHook(this.client);
this.system.addHook(this.slashCommandHook);
// Syscalls available to all plugs
this.system.registerSyscalls(
[],
eventSyscalls(this.eventHook),
editorSyscalls(this.editor),
spaceSyscalls(this.editor),
systemSyscalls(this.editor, this.system),
editorSyscalls(this.client),
spaceSyscalls(this.client),
systemSyscalls(this.client, this.system),
markdownSyscalls(buildMarkdown(this.mdExtensions)),
assetSyscalls(this.system),
yamlSyscalls(),
@@ -128,20 +137,19 @@ export class ClientSystem {
storeCalls,
this.indexSyscalls,
debugSyscalls(),
syncSyscalls(this.editor),
// LEGACY
clientStoreSyscalls(storeCalls),
syncSyscalls(this.client),
clientStoreSyscalls(this.kvStore),
);
// Syscalls that require some additional permissions
this.system.registerSyscalls(
["fetch"],
sandboxFetchSyscalls(this.editor),
sandboxFetchSyscalls(this.client),
);
this.system.registerSyscalls(
["shell"],
shellSyscalls(this.editor),
shellSyscalls(this.client),
);
}
@@ -155,7 +163,7 @@ export class ClientSystem {
await this.system.load(
new URL(plugName, location.origin),
createSandbox,
this.editor.settings.plugOverrides,
this.client.settings.plugOverrides,
);
} catch (e: any) {
console.error("Could not load plug", plugName, "error:", e.message);
+2
View File
@@ -35,11 +35,13 @@
window.silverBulletConfig = {
// These {{VARIABLES}} are replaced by http_server.ts
spaceFolderPath: "{{SPACE_PATH}}",
thinClientMode: "{{THIN_CLIENT_MODE}}",
};
// But in case these variables aren't replaced by the server, fall back fully static mode (no sync)
if (window.silverBulletConfig.spaceFolderPath.includes("{{")) {
window.silverBulletConfig = {
spaceFolderPath: "",
thinClientMode: "off",
};
}
</script>
+44
View File
@@ -45,6 +45,7 @@ export class SyncService {
private kvStore: KVStore,
private eventHook: EventHook,
private isSyncCandidate: (path: string) => boolean,
private enabled: boolean,
) {
this.spaceSync = new SpaceSync(
this.localSpacePrimitives,
@@ -74,6 +75,9 @@ export class SyncService {
}
async isSyncing(): Promise<boolean> {
if (!this.enabled) {
return false;
}
const startTime = await this.kvStore.get(syncStartTimeKey);
if (!startTime) {
return false;
@@ -91,11 +95,19 @@ export class SyncService {
}
hasInitialSyncCompleted(): Promise<boolean> {
if (!this.enabled) {
return Promise.resolve(true);
}
// Initial sync has happened when sync progress has been reported at least once, but the syncStartTime has been reset (which happens after sync finishes)
return this.kvStore.has(syncInitialFullSyncCompletedKey);
}
async registerSyncStart(fullSync: boolean): Promise<void> {
if (!this.enabled) {
return;
}
// Assumption: this is called after an isSyncing() check
await this.kvStore.batchSet([
{
@@ -116,6 +128,10 @@ export class SyncService {
}
async registerSyncProgress(status?: SyncStatus): Promise<void> {
if (!this.enabled) {
return;
}
// Emit a sync event at most every 2s
if (status && this.lastReportedSyncStatus < Date.now() - 2000) {
this.eventHook.dispatchEvent("sync:progress", status);
@@ -126,6 +142,10 @@ export class SyncService {
}
async registerSyncStop(isFullSync: boolean): Promise<void> {
if (!this.enabled) {
return;
}
await this.registerSyncProgress();
await this.kvStore.del(syncStartTimeKey);
if (isFullSync) {
@@ -142,6 +162,10 @@ export class SyncService {
// Await a moment when the sync is no longer running
async noOngoingSync(timeout: number): Promise<void> {
if (!this.enabled) {
return;
}
// Not completely safe, could have race condition on setting the syncStartTimeKey
const startTime = Date.now();
while (await this.isSyncing()) {
@@ -155,6 +179,10 @@ export class SyncService {
filesScheduledForSync = new Set<string>();
async scheduleFileSync(path: string): Promise<void> {
if (!this.enabled) {
return;
}
if (this.filesScheduledForSync.has(path)) {
// Already scheduled, no need to duplicate
console.info(`File ${path} already scheduled for sync`);
@@ -167,11 +195,19 @@ export class SyncService {
}
async scheduleSpaceSync(): Promise<void> {
if (!this.enabled) {
return;
}
await this.noOngoingSync(5000);
await this.syncSpace();
}
start() {
if (!this.enabled) {
return;
}
this.syncSpace().catch(console.error);
setInterval(async () => {
@@ -191,6 +227,10 @@ export class SyncService {
}
async syncSpace(): Promise<number> {
if (!this.enabled) {
return 0;
}
if (await this.isSyncing()) {
console.log("Aborting space sync: already syncing");
return 0;
@@ -218,6 +258,10 @@ export class SyncService {
// Syncs a single file
async syncFile(name: string) {
if (!this.enabled) {
return;
}
// console.log("Checking if we can sync file", name);
if (!this.isSyncCandidate(name)) {
console.info("Requested sync, but not a sync candidate", name);
+8 -3
View File
@@ -1,14 +1,19 @@
import { KVStore } from "../../plugos/lib/kv_store.ts";
import { storeSyscalls } from "../../plugos/syscalls/store.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { SysCallMapping } from "../../plugos/system.ts";
// DEPRECATED, use store directly
export function clientStoreSyscalls(
storeCalls: SysCallMapping,
db: KVStore,
): SysCallMapping {
const localStoreCalls = storeSyscalls(db);
return proxySyscalls(
["clientStore.get", "clientStore.set", "clientStore.delete"],
(ctx, name, ...args) => {
return storeCalls[name.replace("clientStore.", "store.")](ctx, ...args);
return localStoreCalls[name.replace("clientStore.", "store.")](
ctx,
...args,
);
},
);
}
+16
View File
@@ -0,0 +1,16 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { Client } from "../client.ts";
import { proxySyscalls } from "./util.ts";
export function indexProxySyscalls(client: Client): SysCallMapping {
return proxySyscalls(client, [
"index.set",
"index.batchSet",
"index.delete",
"index.get",
"index.queryPrefix",
"index.clearPageIndexForPage",
"index.deletePrefixForPage",
"index.clearPageIndex",
]);
}
+18
View File
@@ -0,0 +1,18 @@
import type { SysCallMapping } from "../../plugos/system.ts";
import type { Client } from "../client.ts";
import { proxySyscalls } from "./util.ts";
export function storeProxySyscalls(client: Client): SysCallMapping {
return proxySyscalls(client, [
"store.delete",
"store.deletePrefix",
"store.deleteAll",
"store.set",
"store.batchSet",
"store.batchDelete",
"store.batchGet",
"store.get",
"store.has",
"store.queryPrefix",
]);
}
+33
View File
@@ -0,0 +1,33 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { SyscallResponse } from "../../server/rpc.ts";
import { Client } from "../client.ts";
export function proxySyscalls(client: Client, names: string[]): SysCallMapping {
const syscalls: SysCallMapping = {};
for (const name of names) {
syscalls[name] = async (_ctx, ...args: any[]) => {
if (!client.remoteSpacePrimitives) {
throw new Error("Not supported");
}
const resp = await client.remoteSpacePrimitives.authenticatedFetch(
`${client.remoteSpacePrimitives.url}/.rpc`,
{
method: "POST",
body: JSON.stringify({
operation: "syscall",
name,
args,
}),
},
);
const result: SyscallResponse = await resp.json();
if (result.error) {
console.error("Remote syscall error", result.error);
throw new Error(result.error);
} else {
return result.result;
}
};
}
return syscalls;
}