Work on #508 (thin client)
This commit is contained in:
+9
-5
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user