diff --git a/cli/plug_run.ts b/cli/plug_run.ts index 62be84ce..2810f8aa 100644 --- a/cli/plug_run.ts +++ b/cli/plug_run.ts @@ -8,6 +8,9 @@ import { EndpointHook } from "../plugos/hooks/endpoint.ts"; import { LocalShell } from "../server/shell_backend.ts"; import { Hono } from "../server/deps.ts"; import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; +import { DataStore } from "../plugos/lib/datastore.ts"; +import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts"; +import { EventHook } from "../plugos/hooks/event.ts"; export async function runPlug( spacePath: string, @@ -23,6 +26,10 @@ export async function runPlug( const endpointHook = new EndpointHook("/_/"); + const ds = new DataStore(kvPrimitives); + const mq = new DataStoreMQ(ds); + const eventHook = new EventHook(); + const serverSystem = new ServerSystem( new AssetBundlePlugSpacePrimitives( new DiskSpacePrimitives(spacePath), @@ -30,6 +37,9 @@ export async function runPlug( ), kvPrimitives, new LocalShell(spacePath), + mq, + ds, + eventHook, false, true, ); diff --git a/common/common_system.ts b/common/common_system.ts new file mode 100644 index 00000000..91b69fda --- /dev/null +++ b/common/common_system.ts @@ -0,0 +1,72 @@ +import { AppCommand, CommandHook } from "./hooks/command.ts"; +import { PlugNamespaceHook } from "./hooks/plug_namespace.ts"; +import { SilverBulletHooks } from "./manifest.ts"; +import { buildQueryFunctions } from "./query_functions.ts"; +import { ScriptEnvironment } from "./space_script.ts"; +import { EventHook } from "../plugos/hooks/event.ts"; +import { DataStore } from "../plugos/lib/datastore.ts"; +import { System } from "../plugos/system.ts"; +import { CodeWidgetHook } from "../web/hooks/code_widget.ts"; +import { PanelWidgetHook } from "../web/hooks/panel_widget.ts"; +import { SlashCommandHook } from "../web/hooks/slash_command.ts"; +import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts"; + +export abstract class CommonSystem { + system!: System; + + // Hooks + commandHook!: CommandHook; + slashCommandHook!: SlashCommandHook; + namespaceHook!: PlugNamespaceHook; + codeWidgetHook!: CodeWidgetHook; + panelWidgetHook!: PanelWidgetHook; + + readonly allKnownPages = new Set(); + readonly spaceScriptCommands = new Map(); + + constructor( + protected mq: DataStoreMQ, + protected ds: DataStore, + protected eventHook: EventHook, + public readOnlyMode: boolean, + protected enableSpaceScript: boolean, + ) { + setInterval(() => { + // Timeout after 5s, retries 3 times, otherwise drops the message (no DLQ) + mq.requeueTimeouts(5000, 3, true).catch(console.error); + }, 20000); // Look to requeue every 20s + } + + async loadSpaceScripts() { + let functions = buildQueryFunctions( + this.allKnownPages, + this.system, + ); + const scriptEnv = new ScriptEnvironment(); + if (this.enableSpaceScript) { + try { + await scriptEnv.loadFromSystem(this.system); + console.log( + "Loaded", + Object.keys(scriptEnv.functions).length, + "functions and", + Object.keys(scriptEnv.commands).length, + "commands from space-script", + ); + } catch (e: any) { + console.error("Error loading space-script:", e.message); + } + functions = { ...functions, ...scriptEnv.functions }; + + // Reset the space script commands + this.spaceScriptCommands.clear(); + for (const [name, command] of Object.entries(scriptEnv.commands)) { + this.spaceScriptCommands.set(name, command); + } + + this.commandHook.throttledBuildAllCommands(); + } + // Swap in the expanded function map + this.ds.functionMap = functions; + } +} diff --git a/web/hooks/command.ts b/common/hooks/command.ts similarity index 95% rename from web/hooks/command.ts rename to common/hooks/command.ts index d29f3345..336d97da 100644 --- a/web/hooks/command.ts +++ b/common/hooks/command.ts @@ -43,7 +43,10 @@ export class CommandHook extends EventEmitter editorCommands = new Map(); system!: System; - constructor(private readOnly: boolean) { + constructor( + private readOnly: boolean, + private additionalCommandsMap: Map, + ) { super(); } @@ -76,6 +79,9 @@ export class CommandHook extends EventEmitter } } await this.loadPageTemplateCommands(); + for (const [name, cmd] of this.additionalCommandsMap) { + this.editorCommands.set(name, cmd); + } this.emit("commandsUpdated", this.editorCommands); } diff --git a/common/manifest.ts b/common/manifest.ts index ba539d91..0ae7522c 100644 --- a/common/manifest.ts +++ b/common/manifest.ts @@ -1,7 +1,7 @@ import * as plugos from "../plugos/types.ts"; import { CronHookT } from "../plugos/hooks/cron.ts"; import { EventHookT } from "../plugos/hooks/event.ts"; -import { CommandHookT } from "../web/hooks/command.ts"; +import { CommandHookT } from "./hooks/command.ts"; import { SlashCommandHookT } from "../web/hooks/slash_command.ts"; import { PlugNamespaceHookT } from "./hooks/plug_namespace.ts"; import { CodeWidgetT } from "../web/hooks/code_widget.ts"; diff --git a/common/query_functions.ts b/common/query_functions.ts index e5dc4e05..ebe6dff4 100644 --- a/common/query_functions.ts +++ b/common/query_functions.ts @@ -3,29 +3,15 @@ import { builtinFunctions } from "$sb/lib/builtin_query_functions.ts"; import { System } from "../plugos/system.ts"; import { Query } from "$sb/types.ts"; import { LimitedMap } from "$sb/lib/limited_map.ts"; -import { ScriptEnvironment } from "./space_script.ts"; const pageCacheTtl = 10 * 1000; // 10s -export async function buildQueryFunctions( +export function buildQueryFunctions( allKnownPages: Set, system: System, - enableSpaceScript: boolean, -): Promise { +): FunctionMap { const pageCache = new LimitedMap(10); - const scriptEnv = new ScriptEnvironment(); - if (enableSpaceScript) { - try { - await scriptEnv.loadFromSystem(system); - console.log( - "Loaded", - Object.keys(scriptEnv.functions).length, - "functions from space-script", - ); - } catch (e: any) { - console.error("Error loading space-script:", e.message); - } - } + return { ...builtinFunctions, pageExists(name: string) { @@ -54,7 +40,7 @@ export async function buildQueryFunctions( if (cachedPage) { return cachedPage; } else { - return system.syscall({}, "space.readPage", [name]).then((page) => { + return system.localSyscall("space.readPage", [name]).then((page) => { pageCache.set(name, page, pageCacheTtl); return page; }).catch((e: any) => { @@ -64,6 +50,5 @@ export async function buildQueryFunctions( }); } }, - ...scriptEnv.functions, }; } diff --git a/common/space_script.ts b/common/space_script.ts index 4ae4826e..3b8db11f 100644 --- a/common/space_script.ts +++ b/common/space_script.ts @@ -1,21 +1,47 @@ -// deno-lint-ignore-file ban-types import { System } from "../plugos/system.ts"; import { ScriptObject } from "../plugs/index/script.ts"; +import { AppCommand, CommandDef } from "./hooks/command.ts"; export class ScriptEnvironment { - functions: Record = {}; + functions: Record any> = {}; + commands: Record = {}; // Public API - registerFunction(name: string, fn: Function) { + registerFunction(name: string, fn: (...args: any[]) => any) { this.functions[name] = fn; } + registerCommand(command: CommandDef, fn: (...args: any[]) => any) { + this.commands[command.name] = { + command, + run: (...args: any[]) => { + return new Promise((resolve) => { + // Next tick + setTimeout(() => { + resolve(fn(...args)); + }); + }); + }, + }; + } + // Internal API evalScript(script: string, system: System) { try { - Function("silverbullet", "syscall", script)( + const fn = Function( + "silverbullet", + "syscall", + "Deno", + "window", + "globalThis", + "self", + script, + ); + fn.call( + {}, this, (name: string, ...args: any[]) => system.syscall({}, name, args), + // The rest is explicitly left to be undefined to prevent access to the global scope ); } catch (e: any) { throw new Error( diff --git a/common/syscalls/system.ts b/common/syscalls/system.ts index 68b51a9f..a75f51b6 100644 --- a/common/syscalls/system.ts +++ b/common/syscalls/system.ts @@ -1,15 +1,15 @@ import { SyscallMeta } from "$sb/types.ts"; import { SysCallMapping, System } from "../../plugos/system.ts"; -import type { ServerSystem } from "../../server/server_system.ts"; import type { Client } from "../../web/client.ts"; -import { CommandDef } from "../../web/hooks/command.ts"; +import { CommandDef } from "../hooks/command.ts"; import { proxySyscall } from "../../web/syscalls/util.ts"; +import type { CommonSystem } from "../common_system.ts"; export function systemSyscalls( system: System, readOnlyMode: boolean, - client: Client | undefined, - serverSystem: ServerSystem | undefined, + commonSystem: CommonSystem, + client?: Client, ): SysCallMapping { const api: SysCallMapping = { "system.invokeFunction": ( @@ -50,8 +50,7 @@ export function systemSyscalls( return client.runCommandByName(name, args); }, "system.listCommands": (): { [key: string]: CommandDef } => { - const commandHook = client?.system.commandHook || - serverSystem!.commandHook; + const commandHook = commonSystem!.commandHook; const allCommands: { [key: string]: CommandDef } = {}; for (const [cmd, def] of commandHook.editorCommands) { allCommands[cmd] = def.command; @@ -76,9 +75,9 @@ export function systemSyscalls( return client.loadPlugs(); }, "system.loadSpaceScripts": async () => { + // Reload scripts locally + await commonSystem.loadSpaceScripts(); if (client) { - // If this is invoked on the client, we need to load the space scripts locally - await client.loadSpaceScripts(); // And we are in a hybrud mode, tell the server to do the same if (system.env === "client") { console.info( @@ -91,10 +90,6 @@ export function systemSyscalls( [], ); } - } else if (serverSystem) { - return serverSystem.loadSpaceScripts(); - } else { - throw new Error("Load space scripts in an undefined environment"); } }, "system.getEnv": () => { diff --git a/plug-api/silverbullet-syscall/system.ts b/plug-api/silverbullet-syscall/system.ts index 7f744587..6b84b3d1 100644 --- a/plug-api/silverbullet-syscall/system.ts +++ b/plug-api/silverbullet-syscall/system.ts @@ -1,4 +1,4 @@ -import type { CommandDef } from "../../web/hooks/command.ts"; +import type { CommandDef } from "../../common/hooks/command.ts"; import { SyscallMeta } from "$sb/types.ts"; import { syscall } from "./syscall.ts"; diff --git a/server/instance.ts b/server/instance.ts index 689bcc1b..b98404a1 100644 --- a/server/instance.ts +++ b/server/instance.ts @@ -5,7 +5,10 @@ import { ReadOnlySpacePrimitives } from "../common/spaces/ro_space_primitives.ts import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; import { ensureAndLoadSettingsAndIndex } from "../common/util.ts"; import { AssetBundle } from "../plugos/asset_bundle/bundle.ts"; +import { EventHook } from "../plugos/hooks/event.ts"; +import { DataStore } from "../plugos/lib/datastore.ts"; import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; +import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts"; import { System } from "../plugos/system.ts"; import { BuiltinSettings } from "../web/types.ts"; import { JWTIssuer } from "./crypto.ts"; @@ -30,6 +33,7 @@ export type SpaceServerConfig = { clientEncryption: boolean; }; +// Equivalent of Client on the server export class SpaceServer { public pagesPath: string; auth?: { user: string; pass: string }; @@ -99,6 +103,11 @@ export class SpaceServer { this.spacePrimitives = new ReadOnlySpacePrimitives(this.spacePrimitives); } + const ds = new DataStore(this.kvPrimitives); + const mq = new DataStoreMQ(ds); + + const eventHook = new EventHook(); + // system = undefined in databaseless mode (no PlugOS instance on the server and no DB) if (!this.syncOnly) { // Enable server-side processing @@ -106,6 +115,9 @@ export class SpaceServer { this.spacePrimitives, this.kvPrimitives, this.shellBackend, + mq, + ds, + eventHook, this.readOnly, this.enableSpaceScript, ); diff --git a/server/server_system.ts b/server/server_system.ts index ebd486e1..622b64ef 100644 --- a/server/server_system.ts +++ b/server/server_system.ts @@ -25,7 +25,6 @@ import { dataStoreReadSyscalls, dataStoreWriteSyscalls, } from "../plugos/syscalls/datastore.ts"; -import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts"; import { languageSyscalls } from "../common/syscalls/language.ts"; import { templateSyscalls } from "../common/syscalls/template.ts"; import { codeWidgetSyscalls } from "../web/syscalls/code_widget.ts"; @@ -35,43 +34,29 @@ import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; import { ShellBackend } from "./shell_backend.ts"; import { ensureSpaceIndex } from "../common/space_index.ts"; import { FileMeta } from "$sb/types.ts"; -import { buildQueryFunctions } from "../common/query_functions.ts"; -import { CommandHook } from "../web/hooks/command.ts"; - -// // Important: load this before the actual plugs -// import { -// createSandbox as noSandboxFactory, -// runWithSystemLock, -// } from "../plugos/sandboxes/no_sandbox.ts"; - -// // Load list of builtin plugs -// import { plug as plugIndex } from "../dist_plug_bundle/_plug/index.plug.js"; -// import { plug as plugFederation } from "../dist_plug_bundle/_plug/federation.plug.js"; -// import { plug as plugQuery } from "../dist_plug_bundle/_plug/query.plug.js"; -// import { plug as plugSearch } from "../dist_plug_bundle/_plug/search.plug.js"; -// import { plug as plugTasks } from "../dist_plug_bundle/_plug/tasks.plug.js"; -// import { plug as plugTemplate } from "../dist_plug_bundle/_plug/template.plug.js"; +import { CommandHook } from "../common/hooks/command.ts"; +import { CommonSystem } from "../common/common_system.ts"; +import { MessageQueue } from "../plugos/lib/mq.ts"; +import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts"; const fileListInterval = 30 * 1000; // 30s const plugNameExtractRegex = /([^/]+)\.plug\.js$/; -export class ServerSystem { - system!: System; - public spacePrimitives!: SpacePrimitives; - // denoKv!: Deno.Kv; +export class ServerSystem extends CommonSystem { listInterval?: number; - ds!: DataStore; - allKnownPages = new Set(); - commandHook!: CommandHook; constructor( - private baseSpacePrimitives: SpacePrimitives, - readonly kvPrimitives: KvPrimitives, + public spacePrimitives: SpacePrimitives, + private kvPrimitives: KvPrimitives, private shellBackend: ShellBackend, - private readOnlyMode: boolean, - private enableSpaceScript: boolean, + mq: DataStoreMQ, + ds: DataStore, + eventHook: EventHook, + readOnlyMode: boolean, + enableSpaceScript: boolean, ) { + super(mq, ds, eventHook, readOnlyMode, enableSpaceScript); } // Always needs to be invoked right after construction @@ -94,24 +79,20 @@ export class ServerSystem { this.system.addHook(eventHook); // Command hook, just for introspection - this.commandHook = new CommandHook(this.readOnlyMode); + this.commandHook = new CommandHook( + this.readOnlyMode, + this.spaceScriptCommands, + ); this.system.addHook(this.commandHook); // Cron hook const cronHook = new CronHook(this.system); this.system.addHook(cronHook); - const mq = new DataStoreMQ(this.ds); - - setInterval(() => { - // Timeout after 5s, retries 3 times, otherwise drops the message (no DLQ) - mq.requeueTimeouts(5000, 3, true).catch(console.error); - }, 20000); // Look to requeue every 20s - const plugNamespaceHook = new PlugNamespaceHook(); this.system.addHook(plugNamespaceHook); - this.system.addHook(new MQHook(this.system, mq)); + this.system.addHook(new MQHook(this.system, this.mq)); const codeWidgetHook = new CodeWidgetHook(); @@ -119,7 +100,7 @@ export class ServerSystem { this.spacePrimitives = new EventedSpacePrimitives( new PlugSpacePrimitives( - this.baseSpacePrimitives, + this.spacePrimitives, plugNamespaceHook, ), eventHook, @@ -133,8 +114,8 @@ export class ServerSystem { spaceReadSyscalls(space), assetSyscalls(this.system), yamlSyscalls(), - systemSyscalls(this.system, this.readOnlyMode, undefined, this), - mqSyscalls(mq), + systemSyscalls(this.system, this.readOnlyMode, this), + mqSyscalls(this.mq), languageSyscalls(), templateSyscalls(this.ds), dataStoreReadSyscalls(this.ds), @@ -167,9 +148,6 @@ export class ServerSystem { await this.loadSpaceScripts(); this.listInterval = setInterval(() => { - // runWithSystemLock(this.system, async () => { - // await space.updatePageList(); - // }); space.updatePageList().catch(console.error); }, fileListInterval); @@ -211,9 +189,7 @@ export class ServerSystem { await indexPromise; } - // await runWithSystemLock(this.system, async () => { await eventHook.dispatchEvent("system:ready"); - // }); } async loadPlugs() { @@ -224,15 +200,6 @@ export class ServerSystem { } } - async loadSpaceScripts() { - // Swap in the expanded function map - this.ds.functionMap = await buildQueryFunctions( - this.allKnownPages, - this.system, - this.enableSpaceScript, - ); - } - async loadPlugFromSpace(path: string): Promise> { const { meta, data } = await this.spacePrimitives.readFile(path); const plugName = path.match(plugNameExtractRegex)![1]; diff --git a/silverbullet.ts b/silverbullet.ts index 3d8ec509..3ea458fb 100755 --- a/silverbullet.ts +++ b/silverbullet.ts @@ -10,6 +10,12 @@ import { plugCompileCommand } from "./cmd/plug_compile.ts"; import { plugRunCommand } from "./cmd/plug_run.ts"; import { syncCommand } from "./cmd/sync.ts"; +// Unhandled rejection, let's not crash +globalThis.addEventListener("unhandledrejection", (event) => { + console.error("Unhandled rejection:", event); + event.preventDefault(); +}); + await new Command() .name("silverbullet") .description("Markdown as a platform") diff --git a/web/client.ts b/web/client.ts index 787a12ae..b9e408d5 100644 --- a/web/client.ts +++ b/web/client.ts @@ -12,7 +12,7 @@ import { Space } from "./space.ts"; import { FilterOption } from "./types.ts"; import { ensureAndLoadSettingsAndIndex } from "../common/util.ts"; import { EventHook } from "../plugos/hooks/event.ts"; -import { AppCommand } from "./hooks/command.ts"; +import { AppCommand } from "../common/hooks/command.ts"; import { PageState, parsePageRefFromURI, @@ -57,7 +57,6 @@ import { } from "../common/space_index.ts"; import { LimitedMap } from "$sb/lib/limited_map.ts"; import { renderTheTemplate } from "../common/syscalls/template.ts"; -import { buildQueryFunctions } from "../common/query_functions.ts"; import { PageRef } from "$sb/lib/page.ts"; import { ReadOnlySpacePrimitives } from "../common/spaces/ro_space_primitives.ts"; import { KvPrimitives } from "../plugos/lib/kv_primitives.ts"; @@ -83,7 +82,7 @@ declare global { // history.scrollRestoration = "manual"; export class Client { - system!: ClientSystem; + clientSystem!: ClientSystem; editorView!: EditorView; keyHandlerCompartment?: Compartment; @@ -116,8 +115,6 @@ export class Client { spaceKV?: KvPrimitives; mq!: DataStoreMQ; - // Used by the "wiki link" highlighter to check if a page exists - public allKnownPages = new Set(); onLoadPageRef: PageRef; constructor( @@ -148,15 +145,11 @@ export class Client { // Setup message queue this.mq = new DataStoreMQ(this.stateDataStore); - setInterval(() => { - // Timeout after 5s, retries 3 times, otherwise drops the message (no DLQ) - this.mq.requeueTimeouts(5000, 3, true).catch(console.error); - }, 20000); // Look to requeue every 20s // Event hook this.eventHook = new EventHook(); // Instantiate a PlugOS system - this.system = new ClientSystem( + this.clientSystem = new ClientSystem( this, this.mq, this.stateDataStore, @@ -193,7 +186,7 @@ export class Client { this.focus(); - this.system.init(); + this.clientSystem.init(); await this.loadSettings(); @@ -224,7 +217,7 @@ export class Client { } await this.loadPlugs(); - await this.loadSpaceScripts(); + await this.clientSystem.loadSpaceScripts(); await this.initNavigator(); await this.initSync(); @@ -246,15 +239,6 @@ export class Client { this.updatePageListCache().catch(console.error); } - async loadSpaceScripts() { - // Swap in the expanded function map - this.stateDataStore.functionMap = await buildQueryFunctions( - this.allKnownPages, - this.system.system, - window.silverBulletConfig.enableSpaceScript, - ); - } - async loadSettings() { this.settings = await ensureAndLoadSettingsAndIndex( this.space.spacePrimitives, @@ -286,14 +270,14 @@ export class Client { // A full sync just completed if (!initialSync) { // If this was NOT the initial sync let's check if we need to perform a space reindex - ensureSpaceIndex(this.stateDataStore, this.system.system).catch( + ensureSpaceIndex(this.stateDataStore, this.clientSystem.system).catch( console.error, ); } else { // This was the initial sync, let's mark a full index as completed await markFullSpaceIndexComplete(this.stateDataStore); // And load space scripts, which probably weren't loaded before - await this.loadSpaceScripts(); + await this.clientSystem.loadSpaceScripts(); } } if (operations) { @@ -340,7 +324,6 @@ export class Client { pageState.header !== undefined)) ) { setTimeout(() => { - console.log("Kicking off scroll to", pageState.scrollTop); this.editorView.scrollDOM.scrollTop = pageState.scrollTop!; }); adjustedPosition = true; @@ -544,7 +527,7 @@ export class Client { this.plugSpaceRemotePrimitives = new PlugSpacePrimitives( remoteSpacePrimitives, - this.system.namespaceHook, + this.clientSystem.namespaceHook, this.syncMode ? undefined : "client", ); @@ -628,21 +611,21 @@ export class Client { // Caching a list of known pages for the wiki_link highlighter (that checks if a page exists) this.eventHook.addLocalListener("page:saved", (pageName: string) => { // Make sure this page is in the list of known pages - this.allKnownPages.add(pageName); + this.clientSystem.allKnownPages.add(pageName); }); this.eventHook.addLocalListener("page:deleted", (pageName: string) => { - this.allKnownPages.delete(pageName); + this.clientSystem.allKnownPages.delete(pageName); }); this.eventHook.addLocalListener( "file:listed", (allFiles: FileMeta[]) => { // Update list of known pages - this.allKnownPages.clear(); + this.clientSystem.allKnownPages.clear(); allFiles.forEach((f) => { if (f.name.endsWith(".md")) { - this.allKnownPages.add(f.name.slice(0, -3)); + this.clientSystem.allKnownPages.add(f.name.slice(0, -3)); } }); }, @@ -741,11 +724,11 @@ export class Client { async updatePageListCache() { console.log("Updating page list cache"); - if (!this.system.system.loadedPlugs.has("index")) { + if (!this.clientSystem.system.loadedPlugs.has("index")) { console.warn("Index plug not loaded, cannot update page list cache"); return; } - const allPages = await this.system.queryObjects("page", {}); + const allPages = await this.clientSystem.queryObjects("page", {}); this.ui.viewDispatch({ type: "update-page-list", allPages, @@ -828,7 +811,7 @@ export class Client { } async loadPlugs() { - await this.system.reloadPlugsFromSpace(this.space); + await this.clientSystem.reloadPlugsFromSpace(this.space); await this.eventHook.dispatchEvent("system:ready"); await this.dispatchAppEvent("plugs:loaded"); } @@ -932,6 +915,7 @@ export class Client { viewState.showPrompt, ].some(Boolean) ) { + console.log("not focusing"); // Some other modal UI element is visible, don't focus editor now return; } @@ -965,7 +949,7 @@ export class Client { "Navigating to new page in new window", `${location.origin}/${encodePageRef(pageRef)}`, ); - const win = window.open( + const win = globalThis.open( `${location.origin}/${encodePageRef(pageRef)}`, "_blank", ); @@ -1021,13 +1005,14 @@ export class Client { perm: "rw", } as PageMeta, }; - this.system.system.invokeFunction("template.newPage", [pageName]).then( - () => { - this.focus(); - }, - ).catch( - console.error, - ); + this.clientSystem.system.invokeFunction("template.newPage", [pageName]) + .then( + () => { + this.focus(); + }, + ).catch( + console.error, + ); } else { this.flashNotification( `Could not load page ${pageName}: ${e.message}`, diff --git a/web/client_system.ts b/web/client_system.ts index 2a169899..fe1c27a4 100644 --- a/web/client_system.ts +++ b/web/client_system.ts @@ -9,7 +9,7 @@ import { eventSyscalls } from "../plugos/syscalls/event.ts"; import { System } from "../plugos/system.ts"; import type { Client } from "./client.ts"; import { CodeWidgetHook } from "./hooks/code_widget.ts"; -import { CommandHook } from "./hooks/command.ts"; +import { CommandHook } from "../common/hooks/command.ts"; import { SlashCommandHook } from "./hooks/slash_command.ts"; import { clientStoreSyscalls } from "./syscalls/clientStore.ts"; import { debugSyscalls } from "./syscalls/debug.ts"; @@ -41,24 +41,29 @@ import { deepObjectMerge } from "$sb/lib/json.ts"; import { Query } from "$sb/types.ts"; import { PanelWidgetHook } from "./hooks/panel_widget.ts"; import { createKeyBindings } from "./editor_state.ts"; +import { CommonSystem } from "../common/common_system.ts"; +import { DataStoreMQ } from "../plugos/lib/mq.datastore.ts"; const plugNameExtractRegex = /\/(.+)\.plug\.js$/; -export class ClientSystem { - commandHook: CommandHook; - slashCommandHook: SlashCommandHook; - namespaceHook: PlugNamespaceHook; - codeWidgetHook: CodeWidgetHook; - system: System; - panelWidgetHook: PanelWidgetHook; - +/** + * Wrapper around a System, used by the client + */ +export class ClientSystem extends CommonSystem { constructor( private client: Client, - private mq: MessageQueue, - private ds: DataStore, - private eventHook: EventHook, - private readOnlyMode: boolean, + mq: DataStoreMQ, + ds: DataStore, + eventHook: EventHook, + readOnlyMode: boolean, ) { + super( + mq, + ds, + eventHook, + readOnlyMode, + window.silverBulletConfig.enableSpaceScript, + ); // Only set environment to "client" when running in thin client mode, otherwise we run everything locally (hybrid) this.system = new System( client.syncMode ? undefined : "client", @@ -95,7 +100,10 @@ export class ClientSystem { } // Command hook - this.commandHook = new CommandHook(this.readOnlyMode); + this.commandHook = new CommandHook( + this.readOnlyMode, + this.spaceScriptCommands, + ); this.commandHook.on({ commandsUpdated: (commandMap) => { this.client.ui?.viewDispatch({ @@ -158,7 +166,7 @@ export class ClientSystem { eventSyscalls(this.eventHook), editorSyscalls(this.client), spaceReadSyscalls(this.client), - systemSyscalls(this.system, false, this.client, undefined), + systemSyscalls(this.system, false, this, this.client), markdownSyscalls(), assetSyscalls(this.system), yamlSyscalls(), diff --git a/web/cm_plugins/fenced_code.ts b/web/cm_plugins/fenced_code.ts index 2f17f198..fb4fe9ac 100644 --- a/web/cm_plugins/fenced_code.ts +++ b/web/cm_plugins/fenced_code.ts @@ -22,12 +22,13 @@ export function fencedCodePlugin(editor: Client) { } const text = state.sliceDoc(from, to); const [_, lang] = text.match(/^```(\w+)?/)!; - const codeWidgetCallback = editor.system.codeWidgetHook + const codeWidgetCallback = editor.clientSystem.codeWidgetHook .codeWidgetCallbacks .get(lang); - const renderMode = editor.system.codeWidgetHook.codeWidgetModes.get( - lang, - ); + const renderMode = editor.clientSystem.codeWidgetHook.codeWidgetModes + .get( + lang, + ); // Only custom render when we have a custom renderer, and the current page is not a template if (codeWidgetCallback && !isTemplate(state.sliceDoc(0, from))) { // We got a custom renderer! diff --git a/web/cm_plugins/markdown_widget.ts b/web/cm_plugins/markdown_widget.ts index 64f576ff..7813b706 100644 --- a/web/cm_plugins/markdown_widget.ts +++ b/web/cm_plugins/markdown_widget.ts @@ -64,7 +64,7 @@ export class MarkdownWidget extends WidgetType { extendedMarkdownLanguage, widgetContent.markdown!, ); - mdTree = await this.client.system.localSyscall( + mdTree = await this.client.clientSystem.localSyscall( "system.invokeFunction", [ "markdown.expandCodeWidgets", @@ -215,7 +215,7 @@ export class MarkdownWidget extends WidgetType { // Update state in DOM as well for future toggles e.target.dataset.state = newState; console.log("Toggling task", taskRef); - this.client.system.localSyscall( + this.client.clientSystem.localSyscall( "system.invokeFunction", ["tasks.updateTaskState", taskRef, oldState, newState], ).catch( @@ -234,7 +234,7 @@ export class MarkdownWidget extends WidgetType { if (button.widgetTarget) { div.addEventListener("click", () => { console.log("Widget clicked"); - this.client.system.localSyscall("system.invokeFunction", [ + this.client.clientSystem.localSyscall("system.invokeFunction", [ button.invokeFunction, this.from, ]).catch(console.error); @@ -245,7 +245,7 @@ export class MarkdownWidget extends WidgetType { (e) => { e.stopPropagation(); console.log("Button clicked:", button.description); - this.client.system.localSyscall("system.invokeFunction", [ + this.client.clientSystem.localSyscall("system.invokeFunction", [ button.invokeFunction, this.from, ]).then((newContent: string | undefined) => { diff --git a/web/cm_plugins/top_bottom_panels.ts b/web/cm_plugins/top_bottom_panels.ts index 96c0daf8..85630fbd 100644 --- a/web/cm_plugins/top_bottom_panels.ts +++ b/web/cm_plugins/top_bottom_panels.ts @@ -6,7 +6,7 @@ import { MarkdownWidget } from "./markdown_widget.ts"; export function postScriptPrefacePlugin( editor: Client, ) { - const panelWidgetHook = editor.system.panelWidgetHook; + const panelWidgetHook = editor.clientSystem.panelWidgetHook; return decoratorStateField((state: EditorState) => { const widgets: any[] = []; const topCallback = panelWidgetHook.callbacks.get("top"); diff --git a/web/cm_plugins/wiki_link.ts b/web/cm_plugins/wiki_link.ts index fb00ad1a..fe101cb0 100644 --- a/web/cm_plugins/wiki_link.ts +++ b/web/cm_plugins/wiki_link.ts @@ -33,7 +33,7 @@ export function cleanWikiLinkPlugin(client: Client) { const pageRef = parsePageRef(page); pageRef.page = resolvePath(client.currentPage, pageRef.page); const lowerCasePageName = pageRef.page.toLowerCase(); - for (const pageName of client.allKnownPages) { + for (const pageName of client.clientSystem.allKnownPages) { if (pageName.toLowerCase() === lowerCasePageName) { pageExists = true; break; diff --git a/web/components/command_palette.tsx b/web/components/command_palette.tsx index 5512de3f..20352808 100644 --- a/web/components/command_palette.tsx +++ b/web/components/command_palette.tsx @@ -1,7 +1,7 @@ import { isMacLike } from "../../common/util.ts"; import { FilterList } from "./filter.tsx"; import { CompletionContext, CompletionResult, featherIcons } from "../deps.ts"; -import { AppCommand } from "../hooks/command.ts"; +import { AppCommand } from "../../common/hooks/command.ts"; import { BuiltinSettings, FilterOption } from "../types.ts"; import { parseCommand } from "../../common/command.ts"; diff --git a/web/components/panel.tsx b/web/components/panel.tsx index e1b495e4..2c6c11f1 100644 --- a/web/components/panel.tsx +++ b/web/components/panel.tsx @@ -47,7 +47,7 @@ export function Panel({ break; case "syscall": { const { id, name, args } = data; - editor.system.localSyscall(name, args).then( + editor.clientSystem.localSyscall(name, args).then( (result) => { if (!iFrameRef.current?.contentWindow) { // iFrame already went away diff --git a/web/components/widget_sandbox_iframe.ts b/web/components/widget_sandbox_iframe.ts index 61acb1ea..66ecceb3 100644 --- a/web/components/widget_sandbox_iframe.ts +++ b/web/components/widget_sandbox_iframe.ts @@ -123,7 +123,7 @@ export function mountIFrame( case "syscall": { const { id, name, args } = data; try { - const result = await client.system.localSyscall(name, args); + const result = await client.clientSystem.localSyscall(name, args); if (!iframe.contentWindow) { // iFrame already went away return; diff --git a/web/editor_state.ts b/web/editor_state.ts index 9de5edee..5c5ebe16 100644 --- a/web/editor_state.ts +++ b/web/editor_state.ts @@ -99,8 +99,8 @@ export function createEditorState( autocompletion({ override: [ client.editorComplete.bind(client), - client.system.slashCommandHook.slashCommandCompleter.bind( - client.system.slashCommandHook, + client.clientSystem.slashCommandHook.slashCommandCompleter.bind( + client.clientSystem.slashCommandHook, ), ], }), @@ -299,7 +299,7 @@ export function createCommandKeyBindings(client: Client): KeyBinding[] { } // Then add bindings for plug commands - for (const def of client.system.commandHook.editorCommands.values()) { + for (const def of client.clientSystem.commandHook.editorCommands.values()) { if (def.command.key) { // If we've already overridden this command, skip it if (overriddenCommands.has(def.command.key)) { diff --git a/web/editor_ui.tsx b/web/editor_ui.tsx index 79f2d3a4..4b17d3c2 100644 --- a/web/editor_ui.tsx +++ b/web/editor_ui.tsx @@ -210,7 +210,7 @@ export class MainUI { return; } console.log("Now renaming page to...", newName); - await client.system.system.invokeFunction( + await client.clientSystem.system.invokeFunction( "index.renamePageCommand", [{ page: newName }], ); @@ -250,26 +250,29 @@ export class MainUI { }] : [], ...viewState.settings.actionButtons - .filter((button) => (typeof button.mobile === "undefined") || (button.mobile === viewState.isMobile)) - .map((button) => { - const parsedCommand = parseCommand(button.command); - let featherIcon = - (featherIcons as any)[kebabToCamel(button.icon)]; - if (!featherIcon) { - featherIcon = featherIcons.HelpCircle; - } - return { - icon: featherIcon, - description: button.description || "", - callback: () => { - client.runCommandByName( - parsedCommand.name, - parsedCommand.args, - ); - }, - href: "", - }; - }), + .filter((button) => + (typeof button.mobile === "undefined") || + (button.mobile === viewState.isMobile) + ) + .map((button) => { + const parsedCommand = parseCommand(button.command); + let featherIcon = + (featherIcons as any)[kebabToCamel(button.icon)]; + if (!featherIcon) { + featherIcon = featherIcons.HelpCircle; + } + return { + icon: featherIcon, + description: button.description || "", + callback: () => { + client.runCommandByName( + parsedCommand.name, + parsedCommand.args, + ); + }, + href: "", + }; + }), ]} rhs={!!viewState.panels.rhs.mode && (
{ }); // Replace with whatever the completion is safeRun(async () => { - await this.editor.system.system.invokeFunction( + await this.editor.clientSystem.system.invokeFunction( slashCompletion.invoke, [slashCompletion], ); diff --git a/web/syscalls/editor.ts b/web/syscalls/editor.ts index 31fa0f76..204a57fc 100644 --- a/web/syscalls/editor.ts +++ b/web/syscalls/editor.ts @@ -60,8 +60,10 @@ export function editorSyscalls(client: Client): SysCallMapping { }, "editor.reloadSettingsAndCommands": async () => { await client.loadSettings(); - await client.system.commandHook.buildAllCommands(); - await client.system.system.localSyscall("system.loadSpaceScripts", []); + await client.clientSystem.system.localSyscall( + "system.loadSpaceScripts", + [], + ); }, "editor.openUrl": (_ctx, url: string, existingWindow = false) => { if (!existingWindow) { diff --git a/web/types.ts b/web/types.ts index ef498c0f..cee2a974 100644 --- a/web/types.ts +++ b/web/types.ts @@ -1,6 +1,6 @@ import { Manifest } from "../common/manifest.ts"; import { PageMeta } from "$sb/types.ts"; -import { AppCommand } from "./hooks/command.ts"; +import { AppCommand } from "../common/hooks/command.ts"; import { defaultSettings } from "../common/util.ts"; // Used by FilterBox diff --git a/website/Space Script.md b/website/Space Script.md index f73350fb..68a9c827 100644 --- a/website/Space Script.md +++ b/website/Space Script.md @@ -13,52 +13,49 @@ Space scripts are defined by simply using `space-script` fenced code blocks in y Here is a trivial example: ```space-script -silverbullet.registerFunction("helloSayer", (name) => { - return `Hello ${name}!`; +silverbullet.registerFunction("helloYeller", (name) => { + return `Hello ${name}!`.toUpperCase(); }) ``` -You can now invoke this function as follows: +You can now invoke this function in a template or query: ```template -{{helloSayer("Pete")}} +{{helloYeller("Pete")}} ``` Upon client and server boot, all indexed scripts will be loaded and activated. To reload scripts on-demand, use the {[System: Reload]} command (bound to `Ctrl-Alt-r` for convenience). -If you use things like `console.log` in your script, you will see this output either in your server’s logs or browser’s JavaScript console (depending on how the script will be invoked). +If you use things like `console.log` in your script, you will see this output either in your server’s logs or browser’s JavaScript console (depending on where the script will be invoked). # Runtime Environment & API -Space script is loaded both in the client and server (or only client, if you run in [[Install/Configuration#Security]] `SB_SYNC_ONLY` mode). +Space script is loaded directly in the browser environment on the client, and the Deno environment on the server. -Depending on where code is run, a slightly different JavaScript API will be available. However, code should ideally primarily rely on the following explicitly exposed APIs: +While not very secure, some effort is put into running this code in a clean JavaScript environment, as such the following global variables are not available: `this`, `self`, `Deno`, `window`, and `globalThis`. -* `silverbullet.registerFunction(name, callback)`: register a custom function (see below) -* `syscall(name, args...)`: invoke a syscall +Depending on where code is run (client or server), a slightly different JavaScript API will be available. However, code should ideally primarily rely on the following explicitly exposed APIs: -Many other standard JavaScript APIs are also available, such as: +* `silverbullet.registerFunction(name, callback)`: registers a custom function (see [[#Custom functions]]). +* `silverbullet.registerCommand(definition, callback)`: registers a custom command (see [[#Custom commands]]). +* `syscall(name, args...)`: invoke a syscall (see [[#Syscalls]]). -* [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) -* [Temporal](https://tc39.es/proposal-temporal/docs/) +Many useful standard JavaScript APIs are available, such as: + +* [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (making fetch calls directly from the browser on the client, and via Deno’s fetch implementation on the server) +* [Temporal](https://tc39.es/proposal-temporal/docs/) (implemented via a polyfill) # Custom functions SilverBullet offers a set of [[Functions]] you can use in its [[Template Language]]. You can extend this set of functions using space script using the `silverbullet.registerFunction` API. -Here is a simple example: +Since template rendering happens on the server (except in [[Client Modes#Synced mode]]), this logic is typically executed on the server. -```space-script -silverbullet.registerFunction("helloSayer", (name) => { - return `Hello ${name}!`; -}) -``` +The `silverbullet.registerFunction` API takes two arguments: -You can now invoke this function as follows: +* `name`: the function name to register +* `callback`: the callback function to invoke (can be `async` or not) -```template -{{helloSayer("Pete")}} -``` - -Even though a [[Functions#readPage(name)]] function already exist, you could implement it in Space Script as follows (let’s name it `myReadPage` instead) using the `syscall` API (detailed further in [[#Syscalls]]): +## Example +Even though a [[Functions#readPage(name)]] function already exist, you could implement it in space script as follows (let’s name it `myReadPage`) using the `syscall` API (detailed further in [[#Syscalls]]): ```space-script silverbullet.registerFunction("myReadPage", async (name) => { @@ -75,8 +72,35 @@ This function can be invoked as follows: {{myReadPage("internal/test page")}} ``` +# Custom commands +You can also define custom commands using space script. Commands are _always_ executed on the client. + +Here is an example of defining a custom command using space script: + +```space-script +silverbullet.registerCommand({name: "My First Command"}, async () => { + await syscall("editor.flashNotification", "Hello there!"); +}); +``` + +You can run it via the command palette, or by pushing this [[Markdown/Command links|command link]]: {[My First Command]} + +The `silverbullet.registerCommand` API takes two arguments: + +* `options`: + * `name`: Name of the command + * `key` (optional): Keyboard shortcut for the command (Windows/Linux) + * `mac` (optional): Mac keyboard shortcut for the command + * `hide` (optional): Do not show this command in the command palette + * `requireMode` (optional): Only make this command available in `ro` or `rw` mode. +* `callback`: the callback function to invoke (can be `async` or not) + # Syscalls -You can invoke syscalls to get access to various useful SilverBullet APIs. A syscall is invoked via `syscall(name, arg1, arg2)` and returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) with the result. +The primary way to interact with the SilverBullet environment is using “syscalls”. Syscalls expose SilverBullet functionality largely available both on the client and server in a safe way. + +In your space script, a syscall is invoked via `syscall(name, arg1, arg2)` and usually returns a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) with the result. + +Here are all available syscalls: ```template {{#each @module in {syscall select replace(name, /\.\w+$/, "") as name}}} @@ -84,5 +108,6 @@ You can invoke syscalls to get access to various useful SilverBullet APIs. A sys {{#each {syscall where @module.name = replace(name, /\.\w+$/, "")}}} * `{{name}}` {{/each}} + {{/each}} ```