Work on client modes

This commit is contained in:
Zef Hemel
2023-08-29 21:17:29 +02:00
parent 5ff1a8bae3
commit 9a005f26b5
54 changed files with 594 additions and 302 deletions
+4 -3
View File
@@ -1,14 +1,15 @@
import { safeRun } from "../common/util.ts";
import { Client } from "./client.ts";
const thinClientMode = !!localStorage.getItem("thinClientMode");
const syncMode = window.silverBulletConfig.supportOnlineMode !== "true" ||
!!localStorage.getItem("syncMode");
safeRun(async () => {
console.log("Booting SilverBullet...");
const client = new Client(
document.getElementById("sb-root")!,
thinClientMode,
syncMode,
);
await client.init();
window.client = client;
@@ -22,7 +23,7 @@ if (navigator.serviceWorker) {
.then(() => {
console.log("Service worker registered...");
});
if (!thinClientMode) {
if (syncMode) {
navigator.serviceWorker.ready.then((registration) => {
registration.active!.postMessage({
type: "config",
+31 -24
View File
@@ -16,12 +16,17 @@ import { PathPageNavigator } from "./navigator.ts";
import { AppViewState, BuiltinSettings } from "./types.ts";
import type { AppEvent, CompleteEvent } from "../plug-api/app_event.ts";
import { throttle } from "../common/async_util.ts";
import { throttle } from "$sb/lib/async.ts";
import { PlugSpacePrimitives } from "../common/spaces/plug_space_primitives.ts";
import { IndexedDBSpacePrimitives } from "../common/spaces/indexeddb_space_primitives.ts";
import { FileMetaSpacePrimitives } from "../common/spaces/file_meta_space_primitives.ts";
import { EventedSpacePrimitives } from "../common/spaces/evented_space_primitives.ts";
import { pageSyncInterval, SyncService } from "./sync_service.ts";
import {
ISyncService,
NoSyncSyncService,
pageSyncInterval,
SyncService,
} from "./sync_service.ts";
import { simpleHash } from "../common/crypto.ts";
import { DexieKVStore } from "../plugos/lib/kv_store.dexie.ts";
import { SyncStatus } from "../common/spaces/sync.ts";
@@ -47,6 +52,7 @@ declare global {
// Injected via index.html
silverBulletConfig: {
spaceFolderPath: string;
supportOnlineMode: string;
};
client: Client;
}
@@ -75,7 +81,7 @@ export class Client {
// Track if plugs have been updated since sync cycle
fullSyncCompleted = false;
syncService: SyncService;
syncService: ISyncService;
settings!: BuiltinSettings;
kvStore: DexieKVStore;
mq: DexieMQ;
@@ -88,7 +94,7 @@ export class Client {
constructor(
parent: Element,
private thinClientMode = false,
public syncMode = false,
) {
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
this.dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);
@@ -117,26 +123,26 @@ export class Client {
this.mq,
this.dbPrefix,
this.eventHook,
this.thinClientMode,
);
const localSpacePrimitives = this.initSpace();
this.syncService = new SyncService(
localSpacePrimitives,
this.plugSpaceRemotePrimitives,
this.kvStore,
this.eventHook,
(path) => {
// TODO: At some point we should remove the data.db exception here
return path !== "data.db" &&
// Exclude all plug space primitives paths
!this.plugSpaceRemotePrimitives.isLikelyHandled(path) ||
// Except federated ones
path.startsWith("!");
},
!this.thinClientMode,
);
this.syncService = this.syncMode
? new SyncService(
localSpacePrimitives,
this.plugSpaceRemotePrimitives,
this.kvStore,
this.eventHook,
(path) => {
// TODO: At some point we should remove the data.db exception here
return path !== "data.db" &&
// Exclude all plug space primitives paths
!this.plugSpaceRemotePrimitives.isLikelyHandled(path) ||
// Except federated ones
path.startsWith("!");
},
)
: new NoSyncSyncService(this.space);
this.ui = new MainUI(this);
this.ui.render(parent);
@@ -243,14 +249,15 @@ export class Client {
Math.round(status.filesProcessed / status.totalFiles * 100),
);
});
this.syncService.spaceSync.on({
fileSynced: (meta, direction) => {
this.eventHook.addLocalListener(
"file:synced",
(meta: FileMeta, direction: string) => {
if (meta.name.endsWith(".md") && direction === "secondary->primary") {
// We likely polled the currently open page which trigggered a local update, let's update the editor accordingly
this.space.getPageMeta(meta.name.slice(0, -3));
}
},
});
);
}
private initNavigator() {
@@ -337,7 +344,7 @@ export class Client {
let localSpacePrimitives: SpacePrimitives | undefined;
if (!this.thinClientMode) {
if (this.syncMode) {
localSpacePrimitives = new FilteredSpacePrimitives(
new FileMetaSpacePrimitives(
new EventedSpacePrimitives(
+31 -21
View File
@@ -53,10 +53,9 @@ export class ClientSystem {
private mq: DexieMQ,
dbPrefix: string,
private eventHook: EventHook,
private thinClientMode: boolean,
) {
// Only set environment to "client" when running in thin client mode, otherwise we run everything locally (hybrid)
this.system = new System(thinClientMode ? "client" : undefined);
this.system = new System(client.syncMode ? undefined : "client");
this.system.addHook(this.eventHook);
@@ -68,9 +67,11 @@ export class ClientSystem {
const cronHook = new CronHook(this.system);
this.system.addHook(cronHook);
if (thinClientMode) {
if (!client.syncMode) {
// In non-sync mode, proxy these to the server
this.indexSyscalls = indexProxySyscalls(client);
} else {
// In sync mode, run them locally
this.indexSyscalls = pageIndexSyscalls(
`${dbPrefix}_page_index`,
globalThis.indexedDB,
@@ -83,7 +84,8 @@ export class ClientSystem {
this.system.addHook(this.codeWidgetHook);
// MQ hook
if (!this.thinClientMode) {
if (client.syncMode) {
// Process MQ messages locally
this.system.addHook(new MQHook(this.system, this.mq));
}
@@ -103,19 +105,21 @@ export class ClientSystem {
this.slashCommandHook = new SlashCommandHook(this.client);
this.system.addHook(this.slashCommandHook);
this.eventHook.addLocalListener("plug:changed", async (fileName) => {
console.log("Plug updated, reloading:", fileName);
this.system.unload(fileName);
const plug = await this.system.load(
new URL(`/${fileName}`, location.href),
createSandbox,
this.client.settings.plugOverrides,
);
if ((plug.manifest! as Manifest).syntax) {
// If there are syntax extensions, rebuild the markdown parser immediately
this.updateMarkdownParser();
this.eventHook.addLocalListener("file:changed", async (path: string) => {
if (path.startsWith("_plug/") && path.endsWith(".plug.js")) {
console.log("Plug updated, reloading:", path);
this.system.unload(path);
const plug = await this.system.load(
new URL(`/${path}`, location.href),
createSandbox,
this.client.settings.plugOverrides,
);
if ((plug.manifest! as Manifest).syntax) {
// If there are syntax extensions, rebuild the markdown parser immediately
this.updateMarkdownParser();
}
this.plugsUpdated = true;
}
this.plugsUpdated = true;
});
// Debugging
@@ -139,9 +143,11 @@ export class ClientSystem {
}
registerSyscalls() {
const storeCalls = this.thinClientMode
? storeProxySyscalls(this.client)
: storeSyscalls(this.kvStore);
const storeCalls = this.client.syncMode
// In sync mode handle locally
? storeSyscalls(this.kvStore)
// In non-sync mode proxy to server
: storeProxySyscalls(this.client);
// Slash command hook
this.slashCommandHook = new SlashCommandHook(this.client);
@@ -153,11 +159,15 @@ export class ClientSystem {
eventSyscalls(this.eventHook),
editorSyscalls(this.client),
spaceSyscalls(this.client),
systemSyscalls(this.client, this.system),
systemSyscalls(this.system, this.client),
markdownSyscalls(buildMarkdown(this.mdExtensions)),
assetSyscalls(this.system),
yamlSyscalls(),
this.thinClientMode ? mqProxySyscalls(this.client) : mqSyscalls(this.mq),
this.client.syncMode
// In sync mode handle locally
? mqSyscalls(this.mq)
// In non-sync mode proxy to server
: mqProxySyscalls(this.client),
storeCalls,
this.indexSyscalls,
debugSyscalls(),
+2
View File
@@ -12,6 +12,7 @@ import { MiniEditor } from "./mini_editor.tsx";
export type ActionButton = {
icon: FunctionalComponent<FeatherProps>;
description: string;
class?: string;
callback: () => void;
href?: string;
};
@@ -141,6 +142,7 @@ export function TopBar({
e.stopPropagation();
}}
title={actionButton.description}
className={actionButton.class}
>
<actionButton.icon size={18} />
</button>
+1
View File
@@ -12,6 +12,7 @@ export {
export {
Book as BookIcon,
Home as HomeIcon,
RefreshCw as RefreshCwIcon,
Terminal as TerminalIcon,
} from "https://esm.sh/preact-feather@4.2.1?external=preact";
+36
View File
@@ -10,6 +10,7 @@ import {
BookIcon,
HomeIcon,
preactRender,
RefreshCwIcon,
runScopeHandlers,
TerminalIcon,
useEffect,
@@ -18,6 +19,7 @@ import {
import type { Client } from "./client.ts";
import { Panel } from "./components/panel.tsx";
import { h } from "./deps.ts";
import { async } from "https://cdn.skypack.dev/-/regenerator-runtime@v0.13.9-4Dxus9nU31cBsHxnWq2H/dist=es2020,mode=imports/optimized/regenerator-runtime.js";
export class MainUI {
viewState: AppViewState = initialViewState;
@@ -202,6 +204,40 @@ export class MainUI {
editor.focus();
}}
actionButtons={[
...window.silverBulletConfig.supportOnlineMode === "true"
? [{
icon: RefreshCwIcon,
description: this.editor.syncMode
? "Currently in sync mode: switch to online mode"
: "Currently in online mode: switch to sync mode",
class: this.editor.syncMode ? "sb-enabled" : undefined,
callback: () => {
(async () => {
const newValue = !this.editor.syncMode;
if (newValue) {
if (
await this.editor.confirm(
"This will enable local sync. Are you sure?",
)
) {
localStorage.setItem("syncMode", "true");
location.reload();
}
} else {
if (
await this.editor.confirm(
"This will disable local sync. Are you sure?",
)
) {
localStorage.removeItem("syncMode");
location.reload();
}
}
})().catch(console.error);
},
}]
: [],
{
icon: HomeIcon,
description: `Go home (Alt-h)`,
+3 -1
View File
@@ -34,12 +34,14 @@
};
window.silverBulletConfig = {
// These {{VARIABLES}} are replaced by http_server.ts
spaceFolderPath: "{{SPACE_PATH}}"
spaceFolderPath: "{{SPACE_PATH}}",
supportOnlineMode: "{{SUPPORT_ONLINE_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: "",
supportOnlineMode: false,
};
}
</script>
+6 -6
View File
@@ -89,6 +89,11 @@ self.addEventListener("fetch", (event: any) => {
return cachedResponse;
}
if (!fileContentTable) {
// Not initialzed yet, or in thin client mode, let's just proxy
return fetch(request);
}
const requestUrl = new URL(request.url);
const pathname = requestUrl.pathname;
@@ -119,17 +124,12 @@ async function handleLocalFileRequest(
request: Request,
pathname: string,
): Promise<Response> {
if (!fileContentTable) {
// Not initialzed yet, or explicitly in sync mode (so direct server communication requested)
return fetch(request);
}
if (!db?.isOpen()) {
console.log("Detected that the DB was closed, reopening");
await db!.open();
}
const path = decodeURIComponent(pathname.slice(1));
const data = await fileContentTable.get(path);
const data = await fileContentTable!.get(path);
if (data) {
// console.log("Serving from space", path);
if (!data.meta) {
+2 -1
View File
@@ -2,10 +2,11 @@ import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import { plugPrefix } from "../common/spaces/constants.ts";
import { safeRun } from "../common/util.ts";
import { AttachmentMeta, PageMeta } from "./types.ts";
import { throttle } from "../common/async_util.ts";
import { KVStore } from "../plugos/lib/kv_store.ts";
import { FileMeta } from "$sb/types.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { throttle } from "$sb/lib/async.ts";
const pageWatchInterval = 5000;
+4
View File
@@ -71,6 +71,10 @@
cursor: pointer;
}
.sb-actions button.sb-enabled {
color: var(--action-button-active-color);
}
.sb-actions button:hover {
color: var(--action-button-hover-color);
}
+2
View File
@@ -45,6 +45,7 @@ html {
--action-button-background-color: transparent;
--action-button-color: #292929;
--action-button-hover-color: #0772be;
--action-button-active-color: #0772be;
--editor-caret-color: black;
--editor-selection-background-color: #d7e1f6;
@@ -159,6 +160,7 @@ html[data-theme="dark"] {
--action-button-background-color: transparent;
--action-button-color: #adadad;
--action-button-hover-color: #37a1ed;
--action-button-active-color: #37a1ed;
--editor-caret-color: #fff;
--editor-selection-background-color: #d7e1f630;
+62 -50
View File
@@ -1,4 +1,4 @@
import { sleep } from "../common/async_util.ts";
import { sleep } from "$sb/lib/async.ts";
import type { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import {
SpaceSync,
@@ -7,6 +7,7 @@ import {
} from "../common/spaces/sync.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { KVStore } from "../plugos/lib/kv_store.ts";
import { Space } from "./space.ts";
// Keeps the current sync snapshot
const syncSnapshotKey = "syncSnapshot";
@@ -31,11 +32,21 @@ const spaceSyncInterval = 17 * 1000; // Every 17s or so
// Used from Client
export const pageSyncInterval = 6000;
export interface ISyncService {
start(): void;
isSyncing(): Promise<boolean>;
hasInitialSyncCompleted(): Promise<boolean>;
noOngoingSync(_timeout: number): Promise<void>;
syncFile(name: string): Promise<void>;
scheduleFileSync(_path: string): Promise<void>;
scheduleSpaceSync(): Promise<void>;
}
/**
* The SyncService primarily wraps the SpaceSync engine but also coordinates sync between
* different browser tabs. It is using the KVStore to keep track of sync state.
*/
export class SyncService {
export class SyncService implements ISyncService {
spaceSync: SpaceSync;
lastReportedSyncStatus = Date.now();
@@ -45,7 +56,6 @@ export class SyncService {
private kvStore: KVStore,
private eventHook: EventHook,
private isSyncCandidate: (path: string) => boolean,
private enabled: boolean,
) {
this.spaceSync = new SpaceSync(
this.localSpacePrimitives,
@@ -72,12 +82,15 @@ export class SyncService {
const path = `${name}.md`;
this.scheduleFileSync(path).catch(console.error);
});
this.spaceSync.on({
fileSynced: (meta, direction) => {
eventHook.dispatchEvent("file:synced", meta, direction);
},
});
}
async isSyncing(): Promise<boolean> {
if (!this.enabled) {
return false;
}
const startTime = await this.kvStore.get(syncStartTimeKey);
if (!startTime) {
return false;
@@ -95,19 +108,11 @@ 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([
{
@@ -128,10 +133,6 @@ 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);
@@ -142,10 +143,6 @@ export class SyncService {
}
async registerSyncStop(isFullSync: boolean): Promise<void> {
if (!this.enabled) {
return;
}
await this.registerSyncProgress();
await this.kvStore.del(syncStartTimeKey);
if (isFullSync) {
@@ -162,10 +159,6 @@ 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()) {
@@ -179,10 +172,6 @@ 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`);
@@ -195,19 +184,11 @@ 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 () => {
@@ -227,10 +208,6 @@ 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;
@@ -258,10 +235,6 @@ 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);
@@ -326,10 +299,6 @@ export class SyncService {
}
await this.saveSnapshot(snapshot);
await this.registerSyncStop(false);
// HEAD
// console.log("And done with file sync for", name);
//
//main
}
async saveSnapshot(snapshot: Map<string, SyncStatusItem>) {
@@ -383,3 +352,46 @@ export class SyncService {
return 1;
}
}
/**
* A no-op sync service that doesn't do anything used when running in thin client mode
*/
export class NoSyncSyncService implements ISyncService {
constructor(private space: Space) {
}
isSyncing(): Promise<boolean> {
return Promise.resolve(false);
}
hasInitialSyncCompleted(): Promise<boolean> {
return Promise.resolve(true);
}
noOngoingSync(_timeout: number): Promise<void> {
return Promise.resolve();
}
scheduleFileSync(_path: string): Promise<void> {
return Promise.resolve();
}
scheduleSpaceSync(): Promise<void> {
return Promise.resolve();
}
start() {
setInterval(() => {
// Trigger a page upload for change events
this.space.updatePageList().catch(console.error);
}, spaceSyncInterval);
}
syncSpace(): Promise<number> {
return Promise.resolve(0);
}
syncFile(_name: string): Promise<void> {
return Promise.resolve();
}
}
-11
View File
@@ -171,20 +171,9 @@ export function editorSyscalls(editor: Client): SysCallMapping {
return editor.confirm(message);
},
"editor.getUiOption": (_ctx, key: string): any => {
if (key === "thinClientMode") {
return !!localStorage.getItem("thinClientMode");
}
return (editor.ui.viewState.uiOptions as any)[key];
},
"editor.setUiOption": (_ctx, key: string, value: any) => {
if (key === "thinClientMode") {
if (value) {
localStorage.setItem("thinClientMode", "true");
} else {
localStorage.removeItem("thinClientMode");
}
return;
}
editor.ui.viewDispatch({
type: "set-ui-option",
key,
+18 -6
View File
@@ -5,8 +5,8 @@ import { CommandDef } from "../hooks/command.ts";
import { proxySyscall } from "./util.ts";
export function systemSyscalls(
editor: Client,
system: System<any>,
client?: Client,
): SysCallMapping {
const api: SysCallMapping = {
"system.invokeFunction": (
@@ -38,24 +38,36 @@ export function systemSyscalls(
if (!functionDef) {
throw Error(`Function ${name} not found`);
}
if (functionDef.env && system.env && functionDef.env !== system.env) {
if (
client && functionDef.env && system.env &&
functionDef.env !== system.env
) {
// Proxy to another environment
return proxySyscall(ctx, editor.remoteSpacePrimitives, name, args);
return proxySyscall(ctx, client.remoteSpacePrimitives, name, args);
}
return plug.invoke(name, args);
},
"system.invokeCommand": (_ctx, name: string) => {
return editor.runCommandByName(name);
if (!client) {
throw new Error("Not supported");
}
return client.runCommandByName(name);
},
"system.listCommands": (): { [key: string]: CommandDef } => {
if (!client) {
throw new Error("Not supported");
}
const allCommands: { [key: string]: CommandDef } = {};
for (const [cmd, def] of editor.system.commandHook.editorCommands) {
for (const [cmd, def] of client.system.commandHook.editorCommands) {
allCommands[cmd] = def.command;
}
return allCommands;
},
"system.reloadPlugs": () => {
return editor.loadPlugs();
if (!client) {
throw new Error("Not supported");
}
return client.loadPlugs();
},
"system.getEnv": () => {
return system.env;