From 859657f8b8dcc5744f9d29c2bbfee0a3936a3e53 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Thu, 31 Mar 2022 14:28:07 +0200 Subject: [PATCH] Removed all traces of sockets, real-time collab and other stuff. --- mobile/App.tsx | 2 +- mobile/html/boot.ts | 3 - package.json | 6 +- plugos/plug.ts | 2 + plugos/syscalls/transport.ts | 10 +- plugos/system.ts | 4 +- plugs/core/dates.ts | 2 +- plugs/core/item.ts | 2 +- plugs/core/markdown.ts | 2 +- plugs/core/materialized_queries.ts | 4 +- plugs/core/page.ts | 11 +- plugs/tasks/task.ts | 4 +- server/api.test.ts | 102 ------ server/api_server.ts | 150 --------- server/disk_storage.ts | 50 ++- server/express_server.ts | 201 ++++++++++- server/index_api.ts | 84 ----- server/page_api.ts | 344 ------------------- server/server.ts | 14 - server/syscalls/{page_index.ts => index.ts} | 0 server/syscalls/space.ts | 16 +- server/types.ts | 57 ---- webapp/boot.ts | 4 +- webapp/cm_collab.ts | 196 ----------- webapp/collab.ts | 248 -------------- webapp/components/filter.tsx | 16 +- webapp/components/top_bar.tsx | 6 +- webapp/cursorEffect.ts | 12 - webapp/editor.tsx | 99 ++++-- webapp/markdown/commands.ts | 4 +- webapp/markdown/index.ts | 117 ++++--- webapp/markdown/markdown.ts | 164 +++++---- webapp/reducer.ts | 20 +- webapp/space.ts | 351 +++++++++++++------- webapp/styles/main.scss | 9 + webapp/syscalls/indexer.ts | 2 +- webapp/syscalls/space.ts | 4 +- webapp/syscalls/system.ts | 2 +- webapp/types.ts | 5 + webapp/watcher.ts | 2 - yarn.lock | 156 +-------- 41 files changed, 796 insertions(+), 1691 deletions(-) delete mode 100644 server/api.test.ts delete mode 100644 server/api_server.ts delete mode 100644 server/index_api.ts delete mode 100644 server/page_api.ts rename server/syscalls/{page_index.ts => index.ts} (100%) delete mode 100644 webapp/cm_collab.ts delete mode 100644 webapp/collab.ts delete mode 100644 webapp/cursorEffect.ts diff --git a/mobile/App.tsx b/mobile/App.tsx index 84898664..a69d3abf 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,6 +1,6 @@ import { StatusBar } from "expo-status-bar"; import React from "react"; -import { SafeAreaView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView, StyleSheet, Text } from "react-native"; import { WebView } from "react-native-webview"; function safeRun(fn: () => Promise) { diff --git a/mobile/html/boot.ts b/mobile/html/boot.ts index 7d34953a..efc4bdd3 100644 --- a/mobile/html/boot.ts +++ b/mobile/html/boot.ts @@ -1,6 +1,3 @@ -import { Editor } from "../../webapp/editor"; -import { Space } from "../../webapp/space"; - declare namespace window { var ReactNativeWebView: any; var receiveMessage: any; diff --git a/package.json b/package.json index 450b0a71..e6725137 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,7 @@ "context": "node" }, "test": { - "source": [ - "server/api.test.ts" - ], + "source": [], "outputFormat": "commonjs", "isLibrary": true, "context": "node" @@ -71,8 +69,6 @@ "nodemon": "^2.0.15", "react": "^17.0.2", "react-dom": "^17.0.2", - "socket.io": "^4.4.1", - "socket.io-client": "^4.4.1", "supertest": "^6.2.2", "vm2": "^3.9.9", "yaml": "^1.10.2", diff --git a/plugos/plug.ts b/plugos/plug.ts index c15c1c13..9230c749 100644 --- a/plugos/plug.ts +++ b/plugos/plug.ts @@ -9,6 +9,7 @@ export class Plug { readonly runtimeEnv: RuntimeEnvironment; grantedPermissions: string[] = []; name: string; + version: number; constructor( system: System, @@ -19,6 +20,7 @@ export class Plug { this.name = name; this.sandbox = sandboxFactory(this); this.runtimeEnv = system.runtimeEnv; + this.version = new Date().getTime(); } async load(manifest: Manifest) { diff --git a/plugos/syscalls/transport.ts b/plugos/syscalls/transport.ts index dcfe9f78..34637a59 100644 --- a/plugos/syscalls/transport.ts +++ b/plugos/syscalls/transport.ts @@ -1,14 +1,18 @@ -import { SysCallMapping } from "../system"; +import { SyscallContext, SysCallMapping } from "../system"; export function transportSyscalls( names: string[], - transportCall: (name: string, ...args: any[]) => Promise + transportCall: ( + ctx: SyscallContext, + name: string, + ...args: any[] + ) => Promise ): SysCallMapping { let syscalls: SysCallMapping = {}; for (let name of names) { syscalls[name] = (ctx, ...args: any[]) => { - return transportCall(name, ...args); + return transportCall(ctx, name, ...args); }; } diff --git a/plugos/system.ts b/plugos/system.ts index 86bda8fe..8d0d69f6 100644 --- a/plugos/system.ts +++ b/plugos/system.ts @@ -14,8 +14,8 @@ export type SystemEvents = { plugUnloaded: (name: string, plug: Plug) => void; }; -type SyscallContext = { - plug: Plug | null; +export type SyscallContext = { + plug: Plug; }; type SyscallSignature = ( diff --git a/plugs/core/dates.ts b/plugs/core/dates.ts index 0692f6aa..bdc01440 100644 --- a/plugs/core/dates.ts +++ b/plugs/core/dates.ts @@ -1,7 +1,7 @@ import { syscall } from "../lib/syscall"; export async function insertToday() { - console.log("Inserting date"); + console.log("Inserting date!"); let niceDate = new Date().toISOString().split("T")[0]; await syscall("editor.insertAtCursor", niceDate); } diff --git a/plugs/core/item.ts b/plugs/core/item.ts index 71a2f031..c58b74eb 100644 --- a/plugs/core/item.ts +++ b/plugs/core/item.ts @@ -35,5 +35,5 @@ export async function indexItems({ name, text }: IndexEvent) { }); } console.log("Found", items.length, "item(s)"); - await syscall("indexer.batchSet", name, items); + await syscall("index.batchSet", name, items); } diff --git a/plugs/core/markdown.ts b/plugs/core/markdown.ts index 39b4208a..49ae0a93 100644 --- a/plugs/core/markdown.ts +++ b/plugs/core/markdown.ts @@ -15,7 +15,7 @@ export async function renderMD() { } }, }); - console.log("output peices", JSON.stringify(tree)); + // console.log("output peices", JSON.stringify(tree)); slicesToRemove.reverse().forEach(([from, to]) => { text = text.slice(0, from) + text.slice(to); }); diff --git a/plugs/core/materialized_queries.ts b/plugs/core/materialized_queries.ts index a985c7c9..84c9f42c 100644 --- a/plugs/core/materialized_queries.ts +++ b/plugs/core/materialized_queries.ts @@ -47,7 +47,7 @@ export async function updateMaterializedQueriesOnPage(pageName: string) { key, page, value: { task, complete, children }, - } of await syscall("indexer.scanPrefixGlobal", "task:")) { + } of await syscall("index.scanPrefixGlobal", "task:")) { let [, pos] = key.split(":"); if (!filter || (filter && task.includes(filter))) { results.push( @@ -64,7 +64,7 @@ export async function updateMaterializedQueriesOnPage(pageName: string) { key, page, value: { item, children }, - } of await syscall("indexer.scanPrefixGlobal", "it:")) { + } of await syscall("index.scanPrefixGlobal", "it:")) { let [, pos] = key.split(":"); if (!filter || (filter && item.includes(filter))) { results.push(`* [[${page}@${pos}]] ${item}`); diff --git a/plugs/core/page.ts b/plugs/core/page.ts index 54437d12..f6ee0022 100644 --- a/plugs/core/page.ts +++ b/plugs/core/page.ts @@ -20,7 +20,7 @@ export async function indexLinks({ name, text }: IndexEvent) { }); } console.log("Found", backLinks.length, "wiki link(s)"); - await syscall("indexer.batchSet", name, backLinks); + await syscall("index.batchSet", name, backLinks); } export async function deletePage() { @@ -82,10 +82,7 @@ type BackLink = { }; async function getBackLinks(pageName: string): Promise { - let allBackLinks = await syscall( - "indexer.scanPrefixGlobal", - `pl:${pageName}:` - ); + let allBackLinks = await syscall("index.scanPrefixGlobal", `pl:${pageName}:`); let pagesToUpdate: BackLink[] = []; for (let { key, value } of allBackLinks) { let keyParts = key.split(":"); @@ -129,7 +126,7 @@ export async function pageComplete() { // Server functions export async function reindexSpace() { console.log("Clearing page index..."); - await syscall("indexer.clearPageIndex"); + await syscall("index.clearPageIndex"); console.log("Listing all pages"); let pages = await syscall("space.listPages"); for (let { name } of pages) { @@ -144,5 +141,5 @@ export async function reindexSpace() { export async function clearPageIndex(page: string) { console.log("Clearing page index for page", page); - await syscall("indexer.clearPageIndexForPage", page); + await syscall("index.clearPageIndexForPage", page); } diff --git a/plugs/tasks/task.ts b/plugs/tasks/task.ts index 60fb3b89..cb0ce457 100644 --- a/plugs/tasks/task.ts +++ b/plugs/tasks/task.ts @@ -45,11 +45,11 @@ export async function indexTasks({ name, text }: IndexEvent) { }); } console.log("Found", tasks.length, "task(s)"); - await syscall("indexer.batchSet", name, tasks); + await syscall("index.batchSet", name, tasks); } export async function updateTaskPage() { - let allTasks = await syscall("indexer.scanPrefixGlobal", "task:"); + let allTasks = await syscall("index.scanPrefixGlobal", "task:"); let pageTasks = new Map(); for (let { key, diff --git a/server/api.test.ts b/server/api.test.ts deleted file mode 100644 index f5125399..00000000 --- a/server/api.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "@jest/globals"; - -import { createServer } from "http"; -import { io as Client } from "socket.io-client"; -import { Server } from "socket.io"; -import { SocketServer } from "./api_server"; -import * as path from "path"; -import * as fs from "fs"; -import { SilverBulletHooks } from "../common/manifest"; -import { System } from "../plugos/system"; - -describe("Server test", () => { - let io: Server, - socketServer: SocketServer, - clientSocket: any, - reqId = 0; - const tmpDir = path.join(__dirname, "test"); - - function wsCall(eventName: string, ...args: any[]): Promise { - return new Promise((resolve, reject) => { - reqId++; - clientSocket.once(`${eventName}Resp${reqId}`, (err: any, result: any) => { - if (err) { - reject(err); - } else { - resolve(result); - } - }); - clientSocket.emit(eventName, reqId, ...args); - }); - } - - beforeAll((done) => { - const httpServer = createServer(); - io = new Server(httpServer); - fs.mkdirSync(tmpDir, { recursive: true }); - fs.writeFileSync(`${tmpDir}/test.md`, "This is a simple test"); - httpServer.listen(async () => { - // @ts-ignore - const port = httpServer.address().port; - // @ts-ignore - clientSocket = new Client(`http://localhost:${port}`); - socketServer = new SocketServer( - tmpDir, - io, - new System("server") - ); - clientSocket.on("connect", done); - await socketServer.init(); - }); - }); - - afterAll(() => { - io.close(); - clientSocket.close(); - socketServer.close(); - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("List pages", async () => { - let pages = await wsCall("page.listPages"); - expect(pages.length).toBe(1); - await wsCall("page.writePage", "test2.md", "This is another test"); - let pages2 = await wsCall("page.listPages"); - expect(pages2.length).toBe(2); - await wsCall("page.deletePage", "test2.md"); - let pages3 = await wsCall("page.listPages"); - expect(pages3.length).toBe(1); - }); - - test("Index operations", async () => { - await wsCall("index.clearPageIndexForPage", "test"); - await wsCall("index.set", "test", "testkey", "value"); - expect(await wsCall("index.get", "test", "testkey")).toBe("value"); - await wsCall("index.delete", "test", "testkey"); - expect(await wsCall("index.get", "test", "testkey")).toBe(null); - await wsCall("index.set", "test", "unrelated", 10); - await wsCall("index.set", "test", "unrelated", 12); - await wsCall("index.set", "test2", "complicated", { - name: "Bla", - age: 123123, - }); - await wsCall("index.set", "test", "complicated", { name: "Bla", age: 100 }); - await wsCall("index.set", "test", "complicated2", { - name: "Bla", - age: 101, - }); - expect(await wsCall("index.get", "test", "complicated")).toStrictEqual({ - name: "Bla", - age: 100, - }); - let result = await wsCall("index.scanPrefixForPage", "test", "compli"); - expect(result.length).toBe(2); - let result2 = await wsCall("index.scanPrefixGlobal", "compli"); - expect(result2.length).toBe(3); - await wsCall("index.deletePrefixForPage", "test", "compli"); - let result3 = await wsCall("index.scanPrefixForPage", "test", "compli"); - expect(result3.length).toBe(0); - let result4 = await wsCall("index.scanPrefixGlobal", "compli"); - expect(result4.length).toBe(1); - }); -}); diff --git a/server/api_server.ts b/server/api_server.ts deleted file mode 100644 index 0c99be93..00000000 --- a/server/api_server.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Server, Socket } from "socket.io"; -import { Page } from "./types"; -import * as path from "path"; -import { IndexApi } from "./index_api"; -import { PageApi } from "./page_api"; -import { SilverBulletHooks } from "../common/manifest"; -import { pageIndexSyscalls } from "./syscalls/page_index"; -import { safeRun } from "./util"; -import { System } from "../plugos/system"; - -export class ClientConnection { - openPages = new Set(); - - constructor(readonly sock: Socket) {} -} - -export interface ApiProvider { - init(): Promise; - - api(): Object; -} - -export class SocketServer { - private openPages = new Map(); - private connectedSockets = new Set(); - private apis = new Map(); - readonly rootPath: string; - private serverSocket: Server; - system: System; - - constructor( - rootPath: string, - serverSocket: Server, - system: System - ) { - this.rootPath = path.resolve(rootPath); - this.serverSocket = serverSocket; - this.system = system; - } - - async registerApi(name: string, apiProvider: ApiProvider) { - await apiProvider.init(); - this.apis.set(name, apiProvider); - } - - public async init() { - const indexApi = new IndexApi(this.rootPath); - await this.registerApi("index", indexApi); - this.system.registerSyscalls("indexer", [], pageIndexSyscalls(indexApi.db)); - await this.registerApi( - "page", - new PageApi( - this.rootPath, - this.connectedSockets, - this.openPages, - this.system - ) - ); - - this.serverSocket.on("connection", (socket) => { - const clientConn = new ClientConnection(socket); - - console.log("Connected", socket.id); - this.connectedSockets.add(socket); - - socket.on("disconnect", () => { - console.log("Disconnected", socket.id); - clientConn.openPages.forEach((pageName) => { - safeRun(async () => { - await disconnectPageSocket(pageName); - }); - }); - this.connectedSockets.delete(socket); - }); - - socket.on("page.closePage", (pageName: string) => { - console.log("Client closed page", pageName); - safeRun(async () => { - await disconnectPageSocket(pageName); - }); - clientConn.openPages.delete(pageName); - }); - - const onCall = ( - eventName: string, - cb: (...args: any[]) => Promise - ) => { - socket.on(eventName, (reqId: number, ...args) => { - cb(...args) - .then((result) => { - socket.emit(`${eventName}Resp${reqId}`, null, result); - }) - .catch((err) => { - socket.emit(`${eventName}Resp${reqId}`, err.message); - }); - }); - }; - - const disconnectPageSocket = async (pageName: string) => { - let page = this.openPages.get(pageName); - if (page) { - for (let client of page.clientStates) { - if (client.socket === socket) { - await (this.apis.get("page")! as PageApi).disconnectClient( - client, - page - ); - } - } - } - }; - for (let [apiName, apiProvider] of this.apis) { - Object.entries(apiProvider.api()).forEach(([eventName, cb]) => { - onCall(`${apiName}.${eventName}`, (...args: any[]): any => { - // @ts-ignore - return cb(clientConn, ...args); - }); - }); - } - - onCall( - "invokeFunction", - (plugName: string, name: string, ...args: any[]): Promise => { - let plug = this.system.loadedPlugs.get(plugName); - if (!plug) { - throw new Error(`Plug ${plugName} not loaded`); - } - console.log( - "Invoking function", - name, - "for plug", - plugName, - "as requested over socket" - ); - return plug.invoke(name, args); - } - ); - - console.log("Sending the sytem to the client"); - socket.emit("loadSystem", this.system.toJSON()); - }); - } - - close() { - console.log("Closing server"); - (this.apis.get("index")! as IndexApi).db.destroy().catch((err) => { - console.error(err); - }); - } -} diff --git a/server/disk_storage.ts b/server/disk_storage.ts index a629d498..5bc2e894 100644 --- a/server/disk_storage.ts +++ b/server/disk_storage.ts @@ -1,8 +1,54 @@ import { mkdir, readdir, readFile, stat, unlink, writeFile } from "fs/promises"; import * as path from "path"; import { PageMeta } from "./types"; +import { EventHook } from "../plugos/hooks/event"; -export class DiskStorage { +export interface Storage { + listPages(): Promise; + + readPage(pageName: string): Promise<{ text: string; meta: PageMeta }>; + + writePage(pageName: string, text: string): Promise; + + getPageMeta(pageName: string): Promise; + + deletePage(pageName: string): Promise; +} + +export class EventedStorage implements Storage { + constructor(private wrapped: Storage, private eventHook: EventHook) {} + + listPages(): Promise { + return this.wrapped.listPages(); + } + + readPage(pageName: string): Promise<{ text: string; meta: PageMeta }> { + return this.wrapped.readPage(pageName); + } + + async writePage(pageName: string, text: string): Promise { + const newPageMeta = this.wrapped.writePage(pageName, text); + // This can happen async + this.eventHook.dispatchEvent("page:saved", pageName).then(() => { + return this.eventHook.dispatchEvent("page:index", { + name: pageName, + text: text, + }); + }); + return newPageMeta; + } + + getPageMeta(pageName: string): Promise { + return this.wrapped.getPageMeta(pageName); + } + + async deletePage(pageName: string): Promise { + await this.eventHook.dispatchEvent("page:deleted", pageName); + return this.wrapped.deletePage(pageName); + } +} + +export class DiskStorage implements Storage { rootPath: string; constructor(rootPath: string) { @@ -88,7 +134,7 @@ export class DiskStorage { } } - async deletePage(pageName: string) { + async deletePage(pageName: string): Promise { let localPath = path.join(this.rootPath, pageName + ".md"); await unlink(localPath); } diff --git a/server/express_server.ts b/server/express_server.ts index da5b131c..b734a0a4 100644 --- a/server/express_server.ts +++ b/server/express_server.ts @@ -1,13 +1,26 @@ -import { Express } from "express"; +import express, { Express } from "express"; import { SilverBulletHooks } from "../common/manifest"; import { EndpointHook } from "../plugos/hooks/endpoint"; import { readFile } from "fs/promises"; import { System } from "../plugos/system"; +import cors from "cors"; +import { DiskStorage, EventedStorage, Storage } from "./disk_storage"; +import path from "path"; +import bodyParser from "body-parser"; +import { EventHook } from "../plugos/hooks/event"; +import spaceSyscalls from "./syscalls/space"; +import { eventSyscalls } from "../plugos/syscalls/event"; +import { pageIndexSyscalls } from "./syscalls"; +import knex, { Knex } from "knex"; export class ExpressServer { app: Express; system: System; private rootPath: string; + private storage: Storage; + private distDir: string; + private eventHook: EventHook; + private db: Knex; constructor( app: Express, @@ -17,19 +30,197 @@ export class ExpressServer { ) { this.app = app; this.rootPath = rootPath; + this.distDir = distDir; this.system = system; + // Setup system + this.eventHook = new EventHook(); + system.addHook(this.eventHook); + this.storage = new EventedStorage( + new DiskStorage(rootPath), + this.eventHook + ); + this.db = knex({ + client: "better-sqlite3", + connection: { + filename: path.join(rootPath, "data.db"), + }, + useNullAsDefault: true, + }); + system.registerSyscalls("index", [], pageIndexSyscalls(this.db)); + system.registerSyscalls("space", [], spaceSyscalls(this.storage)); + system.registerSyscalls("event", [], eventSyscalls(this.eventHook)); system.addHook(new EndpointHook(app, "/_")); + } + + async init() { + console.log("Setting up router"); + + let fsRouter = express.Router(); + + // Page list + fsRouter.route("/").get(async (req, res) => { + res.json(await this.storage.listPages()); + }); + + fsRouter.route("/").post(bodyParser.json(), async (req, res) => {}); + + fsRouter + .route(/\/(.+)/) + .get(async (req, res) => { + let pageName = req.params[0]; + console.log("Getting", pageName); + try { + let pageData = await this.storage.readPage(pageName); + res.status(200); + res.header("Last-Modified", "" + pageData.meta.lastModified); + res.header("Content-Type", "text/markdown"); + res.send(pageData.text); + } catch (e) { + res.status(200); + res.send(""); + } + }) + .put(bodyParser.text({ type: "*/*" }), async (req, res) => { + let pageName = req.params[0]; + console.log("Saving", pageName); + + try { + let meta = await this.storage.writePage(pageName, req.body); + res.status(200); + res.header("Last-Modified", "" + meta.lastModified); + res.send("OK"); + } catch (err) { + res.status(500); + res.send("Write failed"); + console.error("Pipeline failed", err); + } + }) + .options(async (req, res) => { + let pageName = req.params[0]; + try { + const meta = await this.storage.getPageMeta(pageName); + res.status(200); + res.header("Last-Modified", "" + meta.lastModified); + res.header("Content-Type", "text/markdown"); + res.send(""); + } catch (e) { + res.status(200); + res.send(""); + } + }) + .delete(async (req, res) => { + let pageName = req.params[0]; + try { + await this.storage.deletePage(pageName); + res.status(200); + res.send("OK"); + } catch (e) { + console.error("Error deleting file", e); + res.status(500); + res.send("OK"); + } + }); + + this.app.use( + "/fs", + cors({ + methods: "GET,HEAD,PUT,OPTIONS,POST,DELETE", + preflightContinue: true, + }), + fsRouter + ); + + let plugRouter = express.Router(); + + // Plug list + plugRouter.get("/", async (req, res) => { + res.json( + [...this.system.loadedPlugs.values()].map(({ name, version }) => ({ + name, + version, + })) + ); + }); + + plugRouter.get("/:name", async (req, res) => { + const plugName = req.params.name; + const plug = this.system.loadedPlugs.get(plugName); + if (!plug) { + res.status(404); + res.send("Not found"); + } else { + res.header("Last-Modified", "" + plug.version); + res.send(plug.manifest); + } + }); + plugRouter.post( + "/:plug/syscall/:name", + bodyParser.json(), + async (req, res) => { + const name = req.params.name; + const plugName = req.params.plug; + const args = req.body as any; + const plug = this.system.loadedPlugs.get(plugName); + if (!plug) { + res.status(404); + return res.send(`Plug ${plugName} not found`); + } + try { + const result = await this.system.syscallWithContext( + { plug }, + name, + args + ); + res.status(200); + res.send(result); + } catch (e: any) { + res.status(500); + return res.send(e.message); + } + } + ); + plugRouter.post( + "/:plug/function/:name", + bodyParser.json(), + async (req, res) => { + const name = req.params.name; + const plugName = req.params.plug; + const args = req.body as any[]; + const plug = this.system.loadedPlugs.get(plugName); + if (!plug) { + res.status(404); + return res.send(`Plug ${plugName} not found`); + } + try { + console.log("Invoking", name, "with args", args); + const result = await plug.invoke(name, args); + res.status(200); + res.send(result); + } catch (e: any) { + res.status(500); + console.log("Error invoking function", e); + return res.send(e.message); + } + } + ); + + this.app.use( + "/plug", + cors({ + methods: "GET,HEAD,PUT,OPTIONS,POST,DELETE", + preflightContinue: true, + }), + plugRouter + ); // Fallback, serve index.html let cachedIndex: string | undefined = undefined; - app.get("/*", async (req, res) => { + this.app.get("/*", async (req, res) => { if (!cachedIndex) { - cachedIndex = await readFile(`${distDir}/index.html`, "utf8"); + cachedIndex = await readFile(`${this.distDir}/index.html`, "utf8"); } res.status(200).header("Content-Type", "text/html").send(cachedIndex); }); } - - async init() {} } diff --git a/server/index_api.ts b/server/index_api.ts deleted file mode 100644 index 74b1664d..00000000 --- a/server/index_api.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { ApiProvider, ClientConnection } from "./api_server"; -import knex, { Knex } from "knex"; -import path from "path"; -import { ensurePageIndexTable, pageIndexSyscalls } from "./syscalls/page_index"; - -type IndexItem = { - page: string; - key: string; - value: any; -}; - -export class IndexApi implements ApiProvider { - db: Knex; - - constructor(rootPath: string) { - this.db = knex({ - client: "better-sqlite3", - connection: { - filename: path.join(rootPath, "data.db"), - }, - useNullAsDefault: true, - }); - } - - async init() { - await ensurePageIndexTable(this.db); - } - - api() { - const syscalls = pageIndexSyscalls(this.db); - const nullContext = { plug: null }; - return { - clearPageIndexForPage: async ( - clientConn: ClientConnection, - page: string - ) => { - console.log("Now going to clear index for", page); - return syscalls.clearPageIndexForPage(nullContext, page); - }, - set: async ( - clientConn: ClientConnection, - page: string, - key: string, - value: any - ) => { - return syscalls.set(nullContext, page, key, value); - }, - get: async (clientConn: ClientConnection, page: string, key: string) => { - return syscalls.get(nullContext, page, key); - }, - delete: async ( - clientConn: ClientConnection, - page: string, - key: string - ) => { - return syscalls.delete(nullContext, page, key); - }, - scanPrefixForPage: async ( - clientConn: ClientConnection, - page: string, - prefix: string - ) => { - return syscalls.scanPrefixForPage(nullContext, page, prefix); - }, - scanPrefixGlobal: async ( - clientConn: ClientConnection, - prefix: string - ) => { - return syscalls.scanPrefixGlobal(nullContext, prefix); - }, - deletePrefixForPage: async ( - clientConn: ClientConnection, - page: string, - prefix: string - ) => { - return syscalls.deletePrefixForPage(nullContext, page, prefix); - }, - - clearPageIndex: async (clientConn: ClientConnection) => { - return syscalls.clearPageIndex(nullContext); - }, - }; - } -} diff --git a/server/page_api.ts b/server/page_api.ts deleted file mode 100644 index 732ce47c..00000000 --- a/server/page_api.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { ClientPageState, Page, PageMeta } from "./types"; -import { ChangeSet } from "@codemirror/state"; -import { Update } from "@codemirror/collab"; -import { ApiProvider, ClientConnection } from "./api_server"; -import { Socket } from "socket.io"; -import { DiskStorage } from "./disk_storage"; -import { safeRun } from "./util"; -import fs from "fs"; -import path from "path"; -import { stat } from "fs/promises"; -import { Cursor, cursorEffect } from "../webapp/cursorEffect"; -import { SilverBulletHooks } from "../common/manifest"; -import { System } from "../plugos/system"; -import { EventHook } from "../plugos/hooks/event"; -import spaceSyscalls from "./syscalls/space"; -import { eventSyscalls } from "../plugos/syscalls/event"; - -export class PageApi implements ApiProvider { - openPages: Map; - pageStore: DiskStorage; - rootPath: string; - connectedSockets: Set; - private system: System; - private eventHook: EventHook; - - constructor( - rootPath: string, - connectedSockets: Set, - openPages: Map, - system: System - ) { - this.pageStore = new DiskStorage(rootPath); - this.rootPath = rootPath; - this.openPages = openPages; - this.connectedSockets = connectedSockets; - this.system = system; - this.eventHook = new EventHook(); - system.addHook(this.eventHook); - system.registerSyscalls("space", [], spaceSyscalls(this)); - system.registerSyscalls("event", [], eventSyscalls(this.eventHook)); - } - - async init(): Promise { - this.fileWatcher(); - // TODO: Move this elsewhere, this doesn't belong here - this.system.on({ - plugLoaded: (plugName, plugDef) => { - console.log("Plug updated on disk, broadcasting to all clients"); - this.connectedSockets.forEach((socket) => { - socket.emit("plugLoaded", plugName, plugDef.manifest); - }); - }, - plugUnloaded: (plugName) => { - console.log("Plug removed on disk, broadcasting to all clients"); - this.connectedSockets.forEach((socket) => { - socket.emit("plugUnloaded", plugName); - }); - }, - }); - } - - broadcastCursors(page: Page) { - page.clientStates.forEach((client) => { - client.socket.emit( - "cursorSnapshot", - page.name, - Object.fromEntries(page.cursors.entries()) - ); - }); - } - - async flushPageToDisk(name: string, page: Page) { - let meta = await this.pageStore.writePage(name, page.text.sliceString(0)); - console.log(`Wrote page ${name} to disk`); - page.meta = meta; - } - - async disconnectClient(client: ClientPageState, page: Page) { - console.log("Disconnecting client"); - page.clientStates.delete(client); - if (page.clientStates.size === 0) { - console.log("No more clients for", page.name, "flushing"); - await this.flushPageToDisk(page.name, page); - this.openPages.delete(page.name); - } else { - page.cursors.delete(client.socket.id); - this.broadcastCursors(page); - } - } - - fileWatcher() { - fs.watch( - this.rootPath, - { - recursive: true, - persistent: false, - }, - (eventType, filename) => { - safeRun(async () => { - if (!filename.endsWith(".md")) { - return; - } - let localPath = path.join(this.rootPath, filename); - let pageName = filename.substring(0, filename.length - 3); - // console.log("Edit in", pageName, eventType); - let modifiedTime = 0; - try { - let s = await stat(localPath); - modifiedTime = s.mtime.getTime(); - } catch (e) { - // File was deleted - console.log("Deleted", pageName); - for (let socket of this.connectedSockets) { - socket.emit("pageDeleted", pageName); - } - return; - } - const openPage = this.openPages.get(pageName); - if (openPage) { - if (openPage.meta.lastModified < modifiedTime) { - console.log("Page changed on disk outside of editor, reloading"); - this.openPages.delete(pageName); - const meta = { - name: pageName, - lastModified: modifiedTime, - } as PageMeta; - for (let client of openPage.clientStates) { - client.socket.emit("pageChanged", meta); - } - } - } - if (eventType === "rename") { - // This most likely means a new file was created, let's push new file listings to all connected sockets - console.log( - "New file created, broadcasting to all connected sockets", - pageName - ); - for (let socket of this.connectedSockets) { - socket.emit("pageCreated", { - name: pageName, - lastModified: modifiedTime, - } as PageMeta); - } - } - }); - } - ); - } - - api() { - return { - openPage: async (clientConn: ClientConnection, pageName: string) => { - let page = this.openPages.get(pageName); - if (!page) { - try { - let { text, meta } = await this.pageStore.readPage(pageName); - page = new Page(pageName, text, meta); - } catch (e) { - console.log("Creating new page", pageName); - page = new Page(pageName, "", { name: pageName, lastModified: 0 }); - } - this.openPages.set(pageName, page); - } - page.clientStates.add( - new ClientPageState(clientConn.sock, page.version) - ); - clientConn.openPages.add(pageName); - console.log("Opened page", pageName); - this.broadcastCursors(page); - return page.toJSON(); - }, - pushUpdates: async ( - clientConn: ClientConnection, - pageName: string, - version: number, - updates: any[] - ): Promise => { - let page = this.openPages.get(pageName); - - if (!page) { - console.error( - "Received updates for not open page", - pageName, - this.openPages.keys() - ); - return false; - } - if (version !== page.version) { - console.error("Invalid version", version, page.version); - return false; - } else { - console.log("Applying", updates.length, "updates to", pageName); - let transformedUpdates = []; - let textChanged = false; - for (let update of updates) { - let changes = ChangeSet.fromJSON(update.changes); - let transformedUpdate = { - changes, - clientID: update.clientID, - effects: update.cursors?.map((c: Cursor) => { - page!.cursors.set(c.userId, c); - return cursorEffect.of(c); - }), - }; - page.updates.push(transformedUpdate); - transformedUpdates.push(transformedUpdate); - let oldText = page.text; - page.text = changes.apply(page.text); - if (oldText !== page.text) { - textChanged = true; - } - } - console.log( - "New version", - page.version, - "Updates buffered:", - page.updates.length - ); - - if (textChanged) { - // Throttle - if (!page.saveTimer) { - page.saveTimer = setTimeout(() => { - safeRun(async () => { - if (page) { - console.log( - "Persisting", - pageName, - " to disk and indexing." - ); - await this.flushPageToDisk(pageName, page); - await this.eventHook.dispatchEvent("page:saved", pageName); - await this.eventHook.dispatchEvent("page:index", { - name: pageName, - text: page.text.sliceString(0), - }); - page.saveTimer = undefined; - } - }); - }, 1000); - } - } - while (page.pending.length) { - page.pending.pop()!(transformedUpdates); - } - return true; - } - }, - - pullUpdates: async ( - clientConn: ClientConnection, - pageName: string, - version: number - ): Promise => { - let page = this.openPages.get(pageName); - // console.log("Pulling updates for", pageName); - if (!page) { - console.error("Fetching updates for not open page"); - return []; - } - // TODO: Optimize this - let oldestVersion = Infinity; - page.clientStates.forEach((client) => { - oldestVersion = Math.min(client.version, oldestVersion); - if (client.socket === clientConn.sock) { - client.version = version; - } - }); - page.flushUpdates(oldestVersion); - if (version < page.version) { - return page.updatesSince(version); - } else { - return new Promise((resolve) => { - page!.pending.push(resolve); - }); - } - }, - - readPage: async ( - clientConn: ClientConnection, - pageName: string - ): Promise<{ text: string; meta: PageMeta }> => { - let page = this.openPages.get(pageName); - if (page) { - console.log("Serving page from memory", pageName); - return { - text: page.text.sliceString(0), - meta: page.meta, - }; - } else { - return this.pageStore.readPage(pageName); - } - }, - - writePage: async ( - clientConn: ClientConnection, - pageName: string, - text: string - ) => { - // Write to disk - let pageMeta = await this.pageStore.writePage(pageName, text); - - // Notify clients that have the page open - let page = this.openPages.get(pageName); - if (page) { - for (let client of page.clientStates) { - client.socket.emit("pageChanged", pageMeta); - } - this.openPages.delete(pageName); - } - // Trigger system events - await this.eventHook.dispatchEvent("page:saved", pageName); - await this.eventHook.dispatchEvent("page:index", { - name: pageName, - text: text, - }); - return pageMeta; - }, - - deletePage: async (clientConn: ClientConnection, pageName: string) => { - this.openPages.delete(pageName); - clientConn.openPages.delete(pageName); - // Cascading of this to all connected clients will be handled by file watcher - await this.pageStore.deletePage(pageName); - await this.eventHook.dispatchEvent("page:deleted", pageName); - }, - - listPages: async (clientConn: ClientConnection): Promise => { - return this.pageStore.listPages(); - }, - - getPageMeta: async ( - clientConn: ClientConnection, - pageName: string - ): Promise => { - let page = this.openPages.get(pageName); - if (page) { - return page.meta; - } - return this.pageStore.getPageMeta(pageName); - }, - }; - } -} diff --git a/server/server.ts b/server/server.ts index a3dcaf7a..ea3881c9 100755 --- a/server/server.ts +++ b/server/server.ts @@ -2,8 +2,6 @@ import express from "express"; import http from "http"; -import {Server} from "socket.io"; -import {SocketServer} from "./api_server"; import yargs from "yargs"; import {hideBin} from "yargs/helpers"; import {SilverBulletHooks} from "../common/manifest"; @@ -31,23 +29,11 @@ const app = express(); const server = http.createServer(app); const system = new System("server"); -const io = new Server(server, { - cors: { - methods: "GET,HEAD,PUT,OPTIONS,POST,DELETE", - preflightContinue: true, - }, -}); - const port = args.port; const distDir = `${__dirname}/../webapp`; app.use("/", express.static(distDir)); -let socketServer = new SocketServer(pagesPath, io, system); -socketServer.init().catch((e) => { - console.error(e); -}); - const expressServer = new ExpressServer(app, pagesPath, distDir, system); expressServer .init() diff --git a/server/syscalls/page_index.ts b/server/syscalls/index.ts similarity index 100% rename from server/syscalls/page_index.ts rename to server/syscalls/index.ts diff --git a/server/syscalls/space.ts b/server/syscalls/space.ts index 7e73ccae..505d04ce 100644 --- a/server/syscalls/space.ts +++ b/server/syscalls/space.ts @@ -1,27 +1,23 @@ import { PageMeta } from "../types"; import { SysCallMapping } from "../../plugos/system"; -import { PageApi } from "../page_api"; -import { ClientConnection } from "../api_server"; +import { Storage } from "../disk_storage"; -export default (pageApi: PageApi): SysCallMapping => { - const api = pageApi.api(); - // @ts-ignore - const dummyConn = new ClientConnection(null); +export default (storage: Storage): SysCallMapping => { return { listPages: (ctx): Promise => { - return api.listPages(dummyConn); + return storage.listPages(); }, readPage: async ( ctx, name: string ): Promise<{ text: string; meta: PageMeta }> => { - return api.readPage(dummyConn, name); + return storage.readPage(name); }, writePage: async (ctx, name: string, text: string): Promise => { - return api.writePage(dummyConn, name, text); + return storage.writePage(name, text); }, deletePage: async (ctx, name: string) => { - return api.deletePage(dummyConn, name); + return storage.deletePage(name); }, }; }; diff --git a/server/types.ts b/server/types.ts index 019fad9c..6ffc1a57 100644 --- a/server/types.ts +++ b/server/types.ts @@ -1,62 +1,5 @@ -import { Update } from "@codemirror/collab"; -import { Text } from "@codemirror/state"; -import { Socket } from "socket.io"; -import { Cursor } from "../webapp/cursorEffect"; -export class ClientPageState { - constructor(public socket: Socket, public version: number) {} -} - export type PageMeta = { name: string; lastModified: number; version?: number; }; - -export class Page { - versionOffset = 0; - updates: Update[] = []; - cursors = new Map(); - clientStates = new Set(); - - pending: ((value: any) => void)[] = []; - - text: Text; - meta: PageMeta; - - saveTimer: NodeJS.Timeout | undefined; - name: string; - - constructor(name: string, text: string, meta: PageMeta) { - this.name = name; - this.text = Text.of(text.split("\n")); - this.meta = meta; - } - - updatesSince(version: number): Update[] { - return this.updates.slice(version - this.versionOffset); - } - - get version(): number { - return this.updates.length + this.versionOffset; - } - - flushUpdates(version: number) { - if (this.versionOffset > version) { - throw Error("This should never happen"); - } - if (this.versionOffset === version) { - return; - } - this.updates = this.updates.slice(version - this.versionOffset); - this.versionOffset = version; - // console.log("Flushed updates, now got", this.updates.length, "updates"); - } - - toJSON() { - return { - text: this.text, - version: this.version, - cursors: Object.fromEntries(this.cursors.entries()), - }; - } -} diff --git a/webapp/boot.ts b/webapp/boot.ts index fbe41fce..8f1f5f3e 100644 --- a/webapp/boot.ts +++ b/webapp/boot.ts @@ -1,10 +1,8 @@ import { Editor } from "./editor"; import { Space } from "./space"; import { safeRun } from "./util"; -import { io } from "socket.io-client"; -let socket = io(); -let editor = new Editor(new Space(socket), document.getElementById("root")!); +let editor = new Editor(new Space(""), document.getElementById("root")!); safeRun(async () => { await editor.init(); diff --git a/webapp/cm_collab.ts b/webapp/cm_collab.ts deleted file mode 100644 index 2d54cce9..00000000 --- a/webapp/cm_collab.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - Annotation, - ChangeSet, - combineConfig, - EditorState, - Extension, - Facet, - StateEffect, - StateField, - Transaction, -} from "@codemirror/state"; - -/// An update is a set of changes and effects. -export interface Update { - /// The changes made by this update. - changes: ChangeSet; - /// The effects in this update. There'll only ever be effects here - /// when you configure your collab extension with a - /// [`sharedEffects`](#collab.collab^config.sharedEffects) option. - effects?: readonly StateEffect[]; - /// The [ID](#collab.CollabConfig.clientID) of the client who - /// created this update. - clientID: string; -} - -class LocalUpdate implements Update { - constructor( - readonly origin: Transaction, - readonly changes: ChangeSet, - readonly effects: readonly StateEffect[], - readonly clientID: string - ) {} -} - -class CollabState { - constructor( - // The version up to which changes have been confirmed. - readonly version: number, - // The local updates that havent been successfully sent to the - // server yet. - readonly unconfirmed: readonly LocalUpdate[] - ) {} -} - -type CollabConfig = { - /// The starting document version. Defaults to 0. - startVersion?: number; - /// This client's identifying [ID](#collab.getClientID). Will be a - /// randomly generated string if not provided. - clientID?: string; - /// It is possible to share information other than document changes - /// through this extension. If you provide this option, your - /// function will be called on each transaction, and the effects it - /// returns will be sent to the server, much like changes are. Such - /// effects are automatically remapped when conflicting remote - /// changes come in. - sharedEffects?: (tr: Transaction) => readonly StateEffect[]; -}; - -const collabConfig = Facet.define< - CollabConfig & { generatedID: string }, - Required ->({ - combine(configs) { - let combined = combineConfig(configs, { - startVersion: 0, - clientID: null as any, - sharedEffects: () => [], - }); - if (combined.clientID == null) - combined.clientID = (configs.length && configs[0].generatedID) || ""; - return combined; - }, -}); - -const collabReceive = Annotation.define(); - -const collabField = StateField.define({ - create(state) { - return new CollabState(state.facet(collabConfig).startVersion, []); - }, - - update(collab: CollabState, tr: Transaction) { - let isSync = tr.annotation(collabReceive); - if (isSync) return isSync; - let { sharedEffects, clientID } = tr.startState.facet(collabConfig); - let effects = sharedEffects(tr); - if (effects.length || !tr.changes.empty) - return new CollabState( - collab.version, - collab.unconfirmed.concat( - new LocalUpdate(tr, tr.changes, effects, clientID) - ) - ); - return collab; - }, -}); - -/// Create an instance of the collaborative editing plugin. -export function collab(config: CollabConfig = {}): Extension { - return [ - collabField, - collabConfig.of({ - generatedID: Math.floor(Math.random() * 1e9).toString(36), - ...config, - }), - ]; -} - -/// Create a transaction that represents a set of new updates received -/// from the authority. Applying this transaction moves the state -/// forward to adjust to the authority's view of the document. -export function receiveUpdates(state: EditorState, updates: readonly Update[]) { - let { version, unconfirmed } = state.field(collabField); - let { clientID } = state.facet(collabConfig); - - version += updates.length; - - let own = 0; - while (own < updates.length && updates[own].clientID == clientID) own++; - if (own) { - unconfirmed = unconfirmed.slice(own); - updates = updates.slice(own); - } - - // If all updates originated with us, we're done. - if (!updates.length) { - console.log("All updates are ours", unconfirmed.length); - return state.update({ - annotations: [collabReceive.of(new CollabState(version, unconfirmed))], - }); - } - - let changes = updates[0].changes, - effects = updates[0].effects || []; - for (let i = 1; i < updates.length; i++) { - let update = updates[i]; - effects = StateEffect.mapEffects(effects, update.changes); - if (update.effects) effects = effects.concat(update.effects); - changes = changes.compose(update.changes); - } - - if (unconfirmed.length) { - unconfirmed = unconfirmed.map((update) => { - let updateChanges = update.changes.map(changes); - changes = changes.map(update.changes, true); - return new LocalUpdate( - update.origin, - updateChanges, - StateEffect.mapEffects(update.effects, changes), - clientID - ); - }); - effects = StateEffect.mapEffects( - effects, - unconfirmed.reduce( - (ch, u) => ch.compose(u.changes), - ChangeSet.empty(unconfirmed[0].changes.length) - ) - ); - } - return state.update({ - changes, - effects, - annotations: [ - Transaction.addToHistory.of(false), - Transaction.remote.of(true), - collabReceive.of(new CollabState(version, unconfirmed)), - ], - filter: false, - }); -} - -/// Returns the set of locally made updates that still have to be sent -/// to the authority. The returned objects will also have an `origin` -/// property that points at the transaction that created them. This -/// may be useful if you want to send along metadata like timestamps. -/// (But note that the updates may have been mapped in the meantime, -/// whereas the transaction is just the original transaction that -/// created them.) -export function sendableUpdates( - state: EditorState -): readonly (Update & { origin: Transaction })[] { - return state.field(collabField).unconfirmed; -} - -/// Get the version up to which the collab plugin has synced with the -/// central authority. -export function getSyncedVersion(state: EditorState) { - return state.field(collabField).version; -} - -/// Get this editor's collaborative editing client ID. -export function getClientID(state: EditorState) { - return state.facet(collabConfig).clientID; -} diff --git a/webapp/collab.ts b/webapp/collab.ts deleted file mode 100644 index 3761fe0f..00000000 --- a/webapp/collab.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - collab, - getSyncedVersion, - receiveUpdates, - sendableUpdates, - Update, -} from "./cm_collab"; -import { RangeSetBuilder } from "@codemirror/rangeset"; -import { Text, Transaction } from "@codemirror/state"; -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate, - WidgetType, -} from "@codemirror/view"; -import { throttle } from "./util"; -import { Cursor, cursorEffect } from "./cursorEffect"; -import { EventEmitter } from "../common/event"; - -const throttleInterval = 250; - -export class CollabDocument { - text: Text; - version: number; - cursors: Map; - - constructor(text: Text, version: number, cursors: Map) { - this.text = text; - this.version = version; - this.cursors = cursors; - } -} - -class CursorWidget extends WidgetType { - userId: string; - color: string; - - constructor(userId: string, color: string) { - super(); - this.userId = userId; - this.color = color; - } - - eq(other: CursorWidget) { - return other.userId == this.userId; - } - - toDOM() { - let el = document.createElement("span"); - el.className = "other-cursor"; - el.style.backgroundColor = this.color; - // let nameSpanContainer = document.createElement("span"); - // nameSpanContainer.className = "cursor-label-container"; - // let nameSpanLabel = document.createElement("label"); - // nameSpanLabel.className = "cursor-label"; - // nameSpanLabel.textContent = this.userId; - // nameSpanContainer.appendChild(nameSpanLabel); - // el.appendChild(nameSpanContainer); - return el; - } -} - -export type CollabEvents = { - cursorSnapshot: (pageName: string, cursors: Map) => void; -}; - -export function collabExtension( - pageName: string, - clientID: string, - doc: CollabDocument, - collabEmitter: EventEmitter, - callbacks: { - pushUpdates: ( - pageName: string, - version: number, - updates: readonly (Update & { origin: Transaction })[] - ) => Promise; - pullUpdates: ( - pageName: string, - version: number - ) => Promise; - reload: () => void; - } -) { - let plugin = ViewPlugin.fromClass( - class { - private pushing = false; - private done = false; - private failedPushes = 0; - private cursorPositions: Map = doc.cursors; - decorations: DecorationSet; - - throttledPush = throttle(() => this.push(), throttleInterval); - - eventHandlers: Partial = { - cursorSnapshot: (pageName, cursors) => { - console.log("Received new cursor snapshot", cursors); - this.cursorPositions = new Map(Object.entries(cursors)); - }, - }; - - buildDecorations(view: EditorView) { - let builder = new RangeSetBuilder(); - - let list = []; - for (let [userId, def] of this.cursorPositions) { - if (userId == clientID) { - continue; - } - list.push({ - pos: def.pos, - widget: Decoration.widget({ - widget: new CursorWidget(userId, def.color), - side: 1, - }), - }); - } - - list - .sort((a, b) => a.pos - b.pos) - .forEach((r) => { - builder.add(r.pos, r.pos, r.widget); - }); - - return builder.finish(); - } - - constructor(private view: EditorView) { - if (pageName) { - this.pull(); - } - this.decorations = this.buildDecorations(view); - collabEmitter.on(this.eventHandlers); - } - - update(update: ViewUpdate) { - if (update.selectionSet) { - let pos = update.state.selection.main.head; - setTimeout(() => { - update.view.dispatch({ - effects: [ - cursorEffect.of({ pos: pos, userId: clientID, color: "red" }), - ], - }); - }); - } - let foundCursorMoves = new Set(); - for (let tx of update.transactions) { - let cursorMove = tx.effects.find((e) => e.is(cursorEffect)); - if (cursorMove) { - foundCursorMoves.add(cursorMove.value.userId); - } - } - // Update cursors - for (let cursor of this.cursorPositions.values()) { - if (foundCursorMoves.has(cursor.userId)) { - // Already got a cursor update for this one, no need to manually map - continue; - } - update.transactions.forEach((tx) => { - cursor.pos = tx.changes.mapPos(cursor.pos); - }); - } - this.decorations = this.buildDecorations(update.view); - if (update.docChanged || foundCursorMoves.size > 0) { - this.throttledPush(); - } - } - - async push() { - let updates = sendableUpdates(this.view.state); - // TODO: compose multiple updates into one - if (this.pushing || !updates.length) return; - this.pushing = true; - let version = getSyncedVersion(this.view.state); - // console.log("Updates", updates, "to apply to version", version); - let success = await callbacks.pushUpdates(pageName, version, updates); - this.pushing = false; - - if (!success && !this.done) { - this.failedPushes++; - if (this.failedPushes > 10) { - // Not sure if 10 is a good number, but YOLO - console.log("10 pushes failed, reloading"); - callbacks.reload(); - return this.destroy(); - } - console.log( - `Push for page ${pageName} failed temporarily, but will try again` - ); - } else { - this.failedPushes = 0; - } - - // Regardless of whether the push failed or new updates came in - // while it was running, try again if there's updates remaining - if (!this.done && sendableUpdates(this.view.state).length) { - this.throttledPush(); - } - } - - async pull() { - while (!this.done) { - let version = getSyncedVersion(this.view.state); - let updates = await callbacks.pullUpdates(pageName, version); - // Pull out cursor updates and update local state - for (let update of updates) { - if (update.effects) { - for (let effect of update.effects) { - if (effect.is(cursorEffect)) { - this.cursorPositions.set(effect.value.userId, { - userId: effect.value.userId, - pos: effect.value.pos, - color: effect.value.color, - }); - } - } - } - } - - // Apply updates locally - this.view.dispatch(receiveUpdates(this.view.state, updates)); - } - } - - destroy() { - this.done = true; - collabEmitter.off(this.eventHandlers); - } - }, - { - decorations: (v) => v.decorations, - } - ); - - return [ - collab({ - startVersion: doc.version, - clientID, - sharedEffects: (tr) => { - return tr.effects.filter((e) => e.is(cursorEffect)); - }, - }), - plugin, - ]; -} diff --git a/webapp/components/filter.tsx b/webapp/components/filter.tsx index a20a13a0..5ca4fe02 100644 --- a/webapp/components/filter.tsx +++ b/webapp/components/filter.tsx @@ -81,8 +81,11 @@ export function FilterList({ let selectedElementRef = useRef(null); - const filter = (e: React.ChangeEvent) => { - const originalPhrase = e.target.value; + function filterUpdate(e: React.ChangeEvent) { + updateFilter(e.target.value); + } + + function updateFilter(originalPhrase: string) { const searchPhrase = originalPhrase.toLowerCase(); if (searchPhrase) { @@ -103,7 +106,11 @@ export function FilterList({ setText(originalPhrase); setSelectionOption(0); - }; + } + + useEffect(() => { + updateFilter(text); + }, [options]); useEffect(() => { searchBoxRef.current!.focus(); @@ -113,6 +120,7 @@ export function FilterList({ function closer() { onSelect(undefined); } + document.addEventListener("click", closer); return () => { @@ -129,7 +137,7 @@ export function FilterList({ value={text} placeholder={placeholder} ref={searchBoxRef} - onChange={filter} + onChange={filterUpdate} onKeyDown={(e: React.KeyboardEvent) => { // console.log("Key up", e.key); if (onKeyPress) { diff --git a/webapp/components/top_bar.tsx b/webapp/components/top_bar.tsx index e81e0761..70730d5e 100644 --- a/webapp/components/top_bar.tsx +++ b/webapp/components/top_bar.tsx @@ -11,19 +11,19 @@ function prettyName(s: string | undefined): string { export function TopBar({ pageName, - status, + unsavedChanges, notifications, onClick, }: { pageName?: string; - status?: string; + unsavedChanges: boolean; notifications: Notification[]; onClick: () => void; }) { return (
- + {prettyName(pageName)} diff --git a/webapp/cursorEffect.ts b/webapp/cursorEffect.ts deleted file mode 100644 index 3e05bfde..00000000 --- a/webapp/cursorEffect.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { StateEffect } from "@codemirror/state"; -export type Cursor = { - pos: number; - userId: string; - color: string; -}; - -export const cursorEffect = StateEffect.define({ - map({ pos, userId, color }, changes) { - return { pos: changes.mapPos(pos), userId, color }; - }, -}); diff --git a/webapp/editor.tsx b/webapp/editor.tsx index 9a0fc294..a35efee4 100644 --- a/webapp/editor.tsx +++ b/webapp/editor.tsx @@ -4,7 +4,7 @@ import { indentWithTab, standardKeymap } from "@codemirror/commands"; import { history, historyKeymap } from "@codemirror/history"; import { bracketMatching } from "@codemirror/matchbrackets"; import { searchKeymap } from "@codemirror/search"; -import { EditorSelection, EditorState, Text } from "@codemirror/state"; +import { EditorSelection, EditorState } from "@codemirror/state"; import { drawSelection, dropCursor, @@ -12,17 +12,17 @@ import { highlightSpecialChars, KeyBinding, keymap, + ViewPlugin, + ViewUpdate, } from "@codemirror/view"; import React, { useEffect, useReducer } from "react"; import ReactDOM from "react-dom"; import { createSandbox as createIFrameSandbox } from "../plugos/environments/iframe_sandbox"; import { AppEvent, AppEventDispatcher, ClickEvent } from "./app_event"; -import { CollabDocument, collabExtension } from "./collab"; import * as commands from "./commands"; import { CommandPalette } from "./components/command_palette"; import { PageNavigator } from "./components/page_navigator"; import { TopBar } from "./components/top_bar"; -import { Cursor } from "./cursorEffect"; import { lineWrapper } from "./line_wrapper"; import { markdown } from "./markdown"; import { PathPageNavigator } from "./navigator"; @@ -56,6 +56,8 @@ class PageState { } } +const saveInterval = 2000; + export class Editor implements AppEventDispatcher { private system = new System("client"); readonly commandHook: CommandHook; @@ -101,17 +103,14 @@ export class Editor implements AppEventDispatcher { this.render(parent); this.editorView = new EditorView({ - state: this.createEditorState( - "", - new CollabDocument(Text.of([""]), 0, new Map()) - ), + state: this.createEditorState("", ""), parent: document.getElementById("editor")!, }); this.pageNavigator = new PathPageNavigator(); this.system.registerSyscalls("editor", [], editorSyscalls(this)); this.system.registerSyscalls("space", [], spaceSyscalls(this)); - this.system.registerSyscalls("indexer", [], indexerSyscalls(this.space)); + this.system.registerSyscalls("index", [], indexerSyscalls(this.space)); this.system.registerSyscalls("system", [], systemSyscalls(this.space)); } @@ -134,12 +133,11 @@ export class Editor implements AppEventDispatcher { }); this.space.on({ - connect: () => { - if (this.currentPage) { - console.log("Connected to socket, fetch fresh?"); - this.flashNotification("Reconnected, reloading page"); - this.reloadPage(); - } + pageCreated: (meta) => { + console.log("Page created", meta); + }, + pageDeleted: (meta) => { + console.log("Page delete", meta); }, pageChanged: (meta) => { if (this.currentPage === meta.name) { @@ -154,11 +152,6 @@ export class Editor implements AppEventDispatcher { pages: pages, }); }, - loadSystem: (systemJSON) => { - safeRun(async () => { - await this.system.replaceAllFromJSON(systemJSON, createIFrameSandbox); - }); - }, plugLoaded: (plugName, plug) => { safeRun(async () => { console.log("Plug load", plugName); @@ -178,6 +171,40 @@ export class Editor implements AppEventDispatcher { } } + saveTimeout: any; + + async save(immediate: boolean = false): Promise { + return new Promise((resolve, reject) => { + if (!this.viewState.unsavedChanges) { + return resolve(); + } + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + this.saveTimeout = setTimeout( + () => { + if (this.currentPage) { + console.log("Saving page", this.currentPage); + this.space + .writePage( + this.currentPage, + this.editorView!.state.sliceDoc(0), + true + ) + .then(() => { + this.viewDispatch({ type: "page-saved" }); + resolve(); + }) + .catch(reject); + } else { + resolve(); + } + }, + immediate ? 0 : saveInterval + ); + }); + } + flashNotification(message: string) { let id = Math.floor(Math.random() * 1000000); this.viewDispatch({ @@ -204,7 +231,7 @@ export class Editor implements AppEventDispatcher { return this.viewState.currentPage; } - createEditorState(pageName: string, doc: CollabDocument): EditorState { + createEditorState(pageName: string, text: string): EditorState { let commandKeyBindings: KeyBinding[] = []; for (let def of this.commandHook.editorCommands.values()) { if (def.command.key) { @@ -223,8 +250,9 @@ export class Editor implements AppEventDispatcher { }); } } + const editor = this; return EditorState.create({ - doc: doc.text, + doc: text, extensions: [ highlightSpecialChars(), history(), @@ -233,11 +261,6 @@ export class Editor implements AppEventDispatcher { customMarkdownStyle, bracketMatching(), closeBrackets(), - collabExtension(pageName, this.space.socket.id, doc, this.space, { - pushUpdates: this.space.pushUpdates.bind(this.space), - pullUpdates: this.space.pullUpdates.bind(this.space), - reload: this.reloadPage.bind(this), - }), autocompletion({ override: [ this.completerHook.plugCompleter.bind(this.completerHook), @@ -292,6 +315,8 @@ export class Editor implements AppEventDispatcher { mac: "Cmd-k", run: (): boolean => { this.viewDispatch({ type: "start-navigate" }); + // asynchornously will dispatch pageListUpdate event + this.space.updatePageListAsync(); return true; }, }, @@ -321,6 +346,16 @@ export class Editor implements AppEventDispatcher { }); }, }), + ViewPlugin.fromClass( + class { + update(update: ViewUpdate): void { + if (update.docChanged) { + editor.viewDispatch({ type: "page-changed" }); + editor.save(); + } + } + } + ), pasteLinkExtension, markdown({ base: customMarkDown, @@ -332,6 +367,7 @@ export class Editor implements AppEventDispatcher { reloadPage() { console.log("Reloading page"); safeRun(async () => { + clearTimeout(this.saveTimeout); await this.loadPage(this.currentPage!); }); } @@ -357,13 +393,13 @@ export class Editor implements AppEventDispatcher { pageState.selection = this.editorView!.state.selection; pageState.scrollTop = this.editorView!.scrollDOM.scrollTop; } - - await this.space.closePage(this.currentPage); + this.space.unwatchPage(this.currentPage); + await this.save(true); } // Fetch next page to open - let doc = await this.space.openPage(pageName); - let editorState = this.createEditorState(pageName, doc); + let doc = await this.space.readPage(pageName); + let editorState = this.createEditorState(pageName, doc.text); let pageState = this.openPages.get(pageName); editorView.setState(editorState); if (!pageState) { @@ -381,6 +417,8 @@ export class Editor implements AppEventDispatcher { editorView.scrollDOM.scrollTop = pageState!.scrollTop; } + this.space.watchPage(pageName); + this.viewDispatch({ type: "page-loaded", name: pageName, @@ -435,6 +473,7 @@ export class Editor implements AppEventDispatcher { { dispatch({ type: "start-navigate" }); }} diff --git a/webapp/markdown/commands.ts b/webapp/markdown/commands.ts index 22e95a31..1477928f 100644 --- a/webapp/markdown/commands.ts +++ b/webapp/markdown/commands.ts @@ -1,8 +1,8 @@ import { + ChangeSpec, + EditorSelection, StateCommand, Text, - EditorSelection, - ChangeSpec, } from "@codemirror/state"; import { syntaxTree } from "@codemirror/language"; import { SyntaxNode, Tree } from "@lezer/common"; diff --git a/webapp/markdown/index.ts b/webapp/markdown/index.ts index b14b9099..9499b3ad 100644 --- a/webapp/markdown/index.ts +++ b/webapp/markdown/index.ts @@ -1,56 +1,87 @@ -import {Prec} from "@codemirror/state" -import {KeyBinding, keymap} from "@codemirror/view" -import {Language, LanguageSupport, LanguageDescription} from "@codemirror/language" -import {MarkdownExtension, MarkdownParser, parseCode} from "@lezer/markdown" -import {html} from "@codemirror/lang-html" -import {commonmarkLanguage, markdownLanguage, mkLang, getCodeParser} from "./markdown" -import {insertNewlineContinueMarkup, deleteMarkupBackward} from "./commands" -export {commonmarkLanguage, markdownLanguage, insertNewlineContinueMarkup, deleteMarkupBackward} +import { Prec } from "@codemirror/state"; +import { KeyBinding, keymap } from "@codemirror/view"; +import { + Language, + LanguageDescription, + LanguageSupport, +} from "@codemirror/language"; +import { MarkdownExtension, MarkdownParser, parseCode } from "@lezer/markdown"; +import { html } from "@codemirror/lang-html"; +import { + commonmarkLanguage, + getCodeParser, + markdownLanguage, + mkLang, +} from "./markdown"; +import { deleteMarkupBackward, insertNewlineContinueMarkup } from "./commands"; + +export { + commonmarkLanguage, + markdownLanguage, + insertNewlineContinueMarkup, + deleteMarkupBackward, +}; /// A small keymap with Markdown-specific bindings. Binds Enter to /// [`insertNewlineContinueMarkup`](#lang-markdown.insertNewlineContinueMarkup) /// and Backspace to /// [`deleteMarkupBackward`](#lang-markdown.deleteMarkupBackward). export const markdownKeymap: readonly KeyBinding[] = [ - {key: "Enter", run: insertNewlineContinueMarkup}, - {key: "Backspace", run: deleteMarkupBackward} -] + { key: "Enter", run: insertNewlineContinueMarkup }, + { key: "Backspace", run: deleteMarkupBackward }, +]; -const htmlNoMatch = html({matchClosingTags: false}) +const htmlNoMatch = html({ matchClosingTags: false }); /// Markdown language support. -export function markdown(config: { - /// When given, this language will be used by default to parse code - /// blocks. - defaultCodeLanguage?: Language | LanguageSupport, - /// A collection of language descriptions to search through for a - /// matching language (with - /// [`LanguageDescription.matchLanguageName`](#language.LanguageDescription^matchLanguageName)) - /// when a fenced code block has an info string. - codeLanguages?: readonly LanguageDescription[], - /// Set this to false to disable installation of the Markdown - /// [keymap](#lang-markdown.markdownKeymap). - addKeymap?: boolean, - /// Markdown parser - /// [extensions](https://github.com/lezer-parser/markdown#user-content-markdownextension) - /// to add to the parser. - extensions?: MarkdownExtension, - /// The base language to use. Defaults to - /// [`commonmarkLanguage`](#lang-markdown.commonmarkLanguage). - base?: Language -} = {}) { - let {codeLanguages, defaultCodeLanguage, addKeymap = true, base: {parser} = commonmarkLanguage} = config - if (!(parser instanceof MarkdownParser)) throw new RangeError("Base parser provided to `markdown` should be a Markdown parser") - let extensions = config.extensions ? [config.extensions] : [] - let support = [htmlNoMatch.support], defaultCode +export function markdown( + config: { + /// When given, this language will be used by default to parse code + /// blocks. + defaultCodeLanguage?: Language | LanguageSupport; + /// A collection of language descriptions to search through for a + /// matching language (with + /// [`LanguageDescription.matchLanguageName`](#language.LanguageDescription^matchLanguageName)) + /// when a fenced code block has an info string. + codeLanguages?: readonly LanguageDescription[]; + /// Set this to false to disable installation of the Markdown + /// [keymap](#lang-markdown.markdownKeymap). + addKeymap?: boolean; + /// Markdown parser + /// [extensions](https://github.com/lezer-parser/markdown#user-content-markdownextension) + /// to add to the parser. + extensions?: MarkdownExtension; + /// The base language to use. Defaults to + /// [`commonmarkLanguage`](#lang-markdown.commonmarkLanguage). + base?: Language; + } = {} +) { + let { + codeLanguages, + defaultCodeLanguage, + addKeymap = true, + base: {parser} = commonmarkLanguage, + } = config; + if (!(parser instanceof MarkdownParser)) + throw new RangeError( + "Base parser provided to `markdown` should be a Markdown parser" + ); + let extensions = config.extensions ? [config.extensions] : []; + let support = [htmlNoMatch.support], + defaultCode; if (defaultCodeLanguage instanceof LanguageSupport) { - support.push(defaultCodeLanguage.support) - defaultCode = defaultCodeLanguage.language + support.push(defaultCodeLanguage.support); + defaultCode = defaultCodeLanguage.language; } else if (defaultCodeLanguage) { - defaultCode = defaultCodeLanguage + defaultCode = defaultCodeLanguage; } - let codeParser = codeLanguages || defaultCode ? getCodeParser(codeLanguages || [], defaultCode) : undefined - extensions.push(parseCode({codeParser, htmlParser: htmlNoMatch.language.parser})) - if (addKeymap) support.push(Prec.high(keymap.of(markdownKeymap))) - return new LanguageSupport(mkLang(parser.configure(extensions)), support) + let codeParser = + codeLanguages || defaultCode + ? getCodeParser(codeLanguages || [], defaultCode) + : undefined; + extensions.push( + parseCode({codeParser, htmlParser: htmlNoMatch.language.parser}) + ); + if (addKeymap) support.push(Prec.high(keymap.of(markdownKeymap))); + return new LanguageSupport(mkLang(parser.configure(extensions)), support); } diff --git a/webapp/markdown/markdown.ts b/webapp/markdown/markdown.ts index 230f4855..8d50bea5 100644 --- a/webapp/markdown/markdown.ts +++ b/webapp/markdown/markdown.ts @@ -1,84 +1,114 @@ import { - Language, defineLanguageFacet, languageDataProp, foldNodeProp, indentNodeProp, - LanguageDescription, ParseContext -} from "@codemirror/language" -import {styleTags, tags as t} from "@codemirror/highlight" -import {parser as baseParser, MarkdownParser, GFM, Subscript, Superscript, Emoji, MarkdownConfig} from "@lezer/markdown" + defineLanguageFacet, + foldNodeProp, + indentNodeProp, + Language, + languageDataProp, + LanguageDescription, + ParseContext, +} from "@codemirror/language"; +import { styleTags, tags as t } from "@codemirror/highlight"; +import { + Emoji, + GFM, + MarkdownParser, + parser as baseParser, + Subscript, + Superscript, +} from "@lezer/markdown"; -const data = defineLanguageFacet({block: {open: ""}}) +const data = defineLanguageFacet({ block: { open: "" } }); export const commonmark = baseParser.configure({ - props: [ - styleTags({ - "Blockquote/...": t.quote, - HorizontalRule: t.contentSeparator, - "ATXHeading1/... SetextHeading1/...": t.heading1, - "ATXHeading2/... SetextHeading2/...": t.heading2, - "ATXHeading3/...": t.heading3, - "ATXHeading4/...": t.heading4, - "ATXHeading5/...": t.heading5, - "ATXHeading6/...": t.heading6, - "Comment CommentBlock": t.comment, - Escape: t.escape, - Entity: t.character, - "Emphasis/...": t.emphasis, - "StrongEmphasis/...": t.strong, - "Link/... Image/...": t.link, - "OrderedList/... BulletList/...": t.list, + props: [ + styleTags({ + "Blockquote/...": t.quote, + HorizontalRule: t.contentSeparator, + "ATXHeading1/... SetextHeading1/...": t.heading1, + "ATXHeading2/... SetextHeading2/...": t.heading2, + "ATXHeading3/...": t.heading3, + "ATXHeading4/...": t.heading4, + "ATXHeading5/...": t.heading5, + "ATXHeading6/...": t.heading6, + "Comment CommentBlock": t.comment, + Escape: t.escape, + Entity: t.character, + "Emphasis/...": t.emphasis, + "StrongEmphasis/...": t.strong, + "Link/... Image/...": t.link, + "OrderedList/... BulletList/...": t.list, - // "CodeBlock/... FencedCode/...": t.blockComment, - "InlineCode CodeText": t.monospace, - URL: t.url, - "HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark": t.processingInstruction, - "CodeInfo LinkLabel": t.labelName, - LinkTitle: t.string, - Paragraph: t.content - }), - foldNodeProp.add(type => { - if (!type.is("Block") || type.is("Document")) return undefined - return (tree, state) => ({from: state.doc.lineAt(tree.from).to, to: tree.to}) - }), - indentNodeProp.add({ - Document: () => null - }), - languageDataProp.add({ - Document: data - }) - ] -}) + // "CodeBlock/... FencedCode/...": t.blockComment, + "InlineCode CodeText": t.monospace, + URL: t.url, + "HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark": + t.processingInstruction, + "CodeInfo LinkLabel": t.labelName, + LinkTitle: t.string, + Paragraph: t.content, + }), + foldNodeProp.add((type) => { + if (!type.is("Block") || type.is("Document")) return undefined; + return (tree, state) => ({ + from: state.doc.lineAt(tree.from).to, + to: tree.to, + }); + }), + indentNodeProp.add({ + Document: () => null, + }), + languageDataProp.add({ + Document: data, + }), + ], +}); export function mkLang(parser: MarkdownParser) { - return new Language(data, parser, parser.nodeSet.types.find(t => t.name == "Document")!) + return new Language( + data, + parser, + parser.nodeSet.types.find((t) => t.name == "Document")! + ); } /// Language support for strict CommonMark. -export const commonmarkLanguage = mkLang(commonmark) +export const commonmarkLanguage = mkLang(commonmark); -const extended = commonmark.configure([GFM, Subscript, Superscript, Emoji, { +const extended = commonmark.configure([ + GFM, + Subscript, + Superscript, + Emoji, + { props: [ - styleTags({ - "TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark": t.processingInstruction, - "TableHeader/...": t.heading, - "Strikethrough/...": t.strikethrough, - TaskMarker: t.atom, - Task: t.list, - Emoji: t.character, - "Subscript Superscript": t.special(t.content), - TableCell: t.content - }) - ] -}]) + styleTags({ + "TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark": + t.processingInstruction, + "TableHeader/...": t.heading, + "Strikethrough/...": t.strikethrough, + TaskMarker: t.atom, + Task: t.list, + Emoji: t.character, + "Subscript Superscript": t.special(t.content), + TableCell: t.content, + }), + ], + }, +]); /// Language support for [GFM](https://github.github.com/gfm/) plus /// subscript, superscript, and emoji syntax. -export const markdownLanguage = mkLang(extended) +export const markdownLanguage = mkLang(extended); -export function getCodeParser(languages: readonly LanguageDescription[], - defaultLanguage?: Language) { - return (info: string) => { - let found = info && LanguageDescription.matchLanguageName(languages, info, true) - if (!found) return defaultLanguage ? defaultLanguage.parser : null - if (found.support) return found.support.language.parser - return ParseContext.getSkippingParser(found.load()) - } +export function getCodeParser( + languages: readonly LanguageDescription[], + defaultLanguage?: Language +) { + return (info: string) => { + let found = + info && LanguageDescription.matchLanguageName(languages, info, true); + if (!found) return defaultLanguage ? defaultLanguage.parser : null; + if (found.support) return found.support.language.parser; + return ParseContext.getSkippingParser(found.load()); + }; } diff --git a/webapp/reducer.ts b/webapp/reducer.ts index a7784728..84c722a1 100644 --- a/webapp/reducer.ts +++ b/webapp/reducer.ts @@ -10,14 +10,24 @@ export default function reducer( return { ...state, allPages: new Set( - [...state.allPages].map((pageMeta) => - pageMeta.name === action.name - ? { ...pageMeta, lastOpened: Date.now() } - : pageMeta - ) + [...state.allPages].map((pageMeta) => + pageMeta.name === action.name + ? {...pageMeta, lastOpened: Date.now()} + : pageMeta + ) ), currentPage: action.name, }; + case "page-changed": + return { + ...state, + unsavedChanges: true, + }; + case "page-saved": + return { + ...state, + unsavedChanges: false, + }; case "start-navigate": return { ...state, diff --git a/webapp/space.ts b/webapp/space.ts index beef6cd5..00ff2a47 100644 --- a/webapp/space.ts +++ b/webapp/space.ts @@ -1,167 +1,266 @@ import { PageMeta } from "./types"; -import { Socket } from "socket.io-client"; -import { Update } from "@codemirror/collab"; -import { ChangeSet, Text, Transaction } from "@codemirror/state"; - -import { CollabDocument, CollabEvents } from "./collab"; -import { cursorEffect } from "./cursorEffect"; import { EventEmitter } from "../common/event"; import { Manifest } from "../common/manifest"; -import { SystemJSON } from "../plugos/system"; +import { safeRun } from "./util"; +import { Plug } from "../plugos/plug"; export type SpaceEvents = { - connect: () => void; pageCreated: (meta: PageMeta) => void; pageChanged: (meta: PageMeta) => void; pageDeleted: (name: string) => void; pageListUpdated: (pages: Set) => void; - loadSystem: (systemJSON: SystemJSON) => void; plugLoaded: (plugName: string, plug: Manifest) => void; plugUnloaded: (plugName: string) => void; -} & CollabEvents; - -export type KV = { - key: string; - value: any; }; +type PlugMeta = { + name: string; + version: number; +}; + +const pageWatchInterval = 2000; +const plugWatchInterval = 5000; + export class Space extends EventEmitter { - socket: Socket; - reqId = 0; - allPages = new Set(); + pageUrl: string; + pageMetaCache = new Map(); + plugMetaCache = new Map(); + watchedPages = new Set(); + saving = false; + private plugUrl: string; + private initialPageListLoad = true; + private initialPlugListLoad = true; - constructor(socket: Socket) { + constructor(url: string) { super(); - this.socket = socket; + this.pageUrl = url + "/fs"; + this.plugUrl = url + "/plug"; + this.watch(); + this.pollPlugs(); + this.updatePageListAsync(); + } - [ - "connect", - "cursorSnapshot", - "pageCreated", - "pageChanged", - "pageDeleted", - "loadSystem", - "plugLoaded", - "plugUnloaded", - ].forEach((eventName) => { - socket.on(eventName, (...args) => { - this.emit(eventName as keyof SpaceEvents, ...args); + public watchPage(pageName: string) { + this.watchedPages.add(pageName); + } + + public unwatchPage(pageName: string) { + this.watchedPages.delete(pageName); + } + + watch() { + setInterval(() => { + safeRun(async () => { + if (this.saving) { + return; + } + for (const pageName of this.watchedPages) { + const oldMeta = this.pageMetaCache.get(pageName); + if (!oldMeta) { + // No longer in cache, meaning probably deleted let's unwatch + this.watchedPages.delete(pageName); + continue; + } + const newMeta = await this.getPageMeta(pageName); + if (oldMeta.lastModified !== newMeta.lastModified) { + console.log("Page", pageName, "changed on disk, emitting event"); + this.emit("pageChanged", newMeta); + } + } }); - }); - this.wsCall("page.listPages").then((pages) => { - this.allPages = new Set(pages); - this.emit("pageListUpdated", this.allPages); - }); - this.on({ - pageCreated: (meta) => { - // Cannot reply on equivalence in set, need to iterate over all pages - let found = false; - for (const page of this.allPages) { - if (page.name === meta.name) { - found = true; - break; - } + }, pageWatchInterval); + + setInterval(() => { + safeRun(this.pollPlugs.bind(this)); + }, plugWatchInterval); + } + + public updatePageListAsync() { + safeRun(async () => { + let req = await fetch(this.pageUrl, { + method: "GET", + }); + + let deletedPages = new Set(this.pageMetaCache.keys()); + ((await req.json()) as any[]).forEach((meta: any) => { + const pageName = meta.name; + const oldPageMeta = this.pageMetaCache.get(pageName); + const newPageMeta = { + name: pageName, + lastModified: meta.lastModified, + }; + if (!oldPageMeta && !this.initialPageListLoad) { + this.emit("pageCreated", newPageMeta); + } else if ( + oldPageMeta && + oldPageMeta.lastModified !== newPageMeta.lastModified + ) { + this.emit("pageChanged", newPageMeta); } - if (!found) { - this.allPages.add(meta); - console.log("New page created", meta); - this.emit("pageListUpdated", this.allPages); - } - }, - pageDeleted: (name) => { - console.log("Page delete", name); - this.allPages.forEach((meta) => { - if (name === meta.name) { - this.allPages.delete(meta); - } - }); - this.emit("pageListUpdated", this.allPages); - }, + // Page found, not deleted + deletedPages.delete(pageName); + + // Update in cache + this.pageMetaCache.set(pageName, newPageMeta); + }); + + for (const deletedPage of deletedPages) { + this.pageMetaCache.delete(deletedPage); + this.emit("pageDeleted", deletedPage); + } + + this.emit("pageListUpdated", new Set([...this.pageMetaCache.values()])); + this.initialPageListLoad = false; }); } - openRequests = new Map(); - public wsCall(eventName: string, ...args: any[]): Promise { - return new Promise((resolve, reject) => { - this.reqId++; - const reqId = this.reqId; - this.openRequests.set(reqId, eventName); - this.socket!.once(`${eventName}Resp${reqId}`, (err, result) => { - this.openRequests.delete(reqId); - if (err) { - reject(new Error(err)); - } else { - resolve(result); - } - }); - this.socket!.emit(eventName, reqId, ...args); - }); + public async listPages(): Promise> { + // this.updatePageListAsync(); + return new Set([...this.pageMetaCache.values()]); } - async pushUpdates( - pageName: string, - version: number, - fullUpdates: readonly (Update & { origin: Transaction })[] - ): Promise { - if (this.socket) { - let updates = fullUpdates.map((u) => ({ - clientID: u.clientID, - changes: u.changes.toJSON(), - cursors: u.effects?.map((e) => e.value), - })); - return this.wsCall("page.pushUpdates", pageName, version, updates); + private responseToMetaCacher(name: string, res: Response): PageMeta { + const meta = { + name, + lastModified: +(res.headers.get("Last-Modified") || "0"), + }; + this.pageMetaCache.set(name, meta); + return meta; + } + + public async readPage( + name: string + ): Promise<{ text: string; meta: PageMeta }> { + let res = await fetch(`${this.pageUrl}/${name}`, { + method: "GET", + }); + return { + text: await res.text(), + meta: this.responseToMetaCacher(name, res), + }; + } + + public async writePage( + name: string, + text: string, + selfUpdate?: boolean + ): Promise { + try { + this.saving = true; + let res = await fetch(`${this.pageUrl}/${name}`, { + method: "PUT", + body: text, + }); + const newMeta = this.responseToMetaCacher(name, res); + if (!selfUpdate) { + this.emit("pageChanged", newMeta); + } + return newMeta; + } finally { + this.saving = false; } - return false; } - async pullUpdates( - pageName: string, - version: number - ): Promise { - let updates: Update[] = await this.wsCall( - "page.pullUpdates", - pageName, - version - ); - return updates.map((u) => ({ - changes: ChangeSet.fromJSON(u.changes), - effects: u.effects?.map((e) => cursorEffect.of(e.value)), - clientID: u.clientID, - })); + public async deletePage(name: string): Promise { + let req = await fetch(`${this.pageUrl}/${name}`, { + method: "DELETE", + }); + if (req.status !== 200) { + throw Error(`Failed to delete page: ${req.statusText}`); + } + this.pageMetaCache.delete(name); + this.emit("pageDeleted", name); + this.emit("pageListUpdated", new Set([...this.pageMetaCache.values()])); } - async listPages(): Promise { - return Array.from(this.allPages); + private async getPageMeta(name: string): Promise { + let res = await fetch(`${this.pageUrl}/${name}`, { + method: "OPTIONS", + }); + return this.responseToMetaCacher(name, res); } - async openPage(name: string): Promise { - this.reqId++; - let pageJSON = await this.wsCall("page.openPage", name); - - return new CollabDocument( - Text.of(pageJSON.text), - pageJSON.version, - new Map(Object.entries(pageJSON.cursors)) - ); + async remoteSyscall( + plug: Plug, + name: string, + args: any[] + ): Promise { + console.log("Making a remote syscall", name, args); + let req = await fetch(`${this.plugUrl}/${plug.name}/syscall/${name}`, { + method: "POST", + headers: { + "Content-type": "application/json", + }, + body: JSON.stringify(args), + }); + if (req.status !== 200) { + let error = await req.text(); + throw Error(error); + } + if (req.headers.get("Content-length") === "0") { + return; + } + return await req.json(); } - async closePage(name: string): Promise { - this.socket.emit("page.closePage", name); + async remoteInvoke(plug: Plug, name: string, args: any[]): Promise { + console.log("Making a remote syscall", name, JSON.stringify(args)); + let req = await fetch(`${this.plugUrl}/${plug.name}/function/${name}`, { + method: "POST", + headers: { + "Content-type": "application/json", + }, + body: JSON.stringify(args), + }); + if (req.status !== 200) { + let error = await req.text(); + throw Error(error); + } + if (req.headers.get("Content-length") === "0") { + return; + } + return await req.json(); } - async readPage(name: string): Promise<{ text: string; meta: PageMeta }> { - return this.wsCall("page.readPage", name); + private async pollPlugs(): Promise { + const newPlugs = await this.loadPlugs(); + let deletedPlugs = new Set(this.plugMetaCache.keys()); + for (const newPlugMeta of newPlugs) { + const oldPlugMeta = this.plugMetaCache.get(newPlugMeta.name); + if ( + !oldPlugMeta || + (oldPlugMeta && oldPlugMeta.version !== newPlugMeta.version) + ) { + this.emit( + "plugLoaded", + newPlugMeta.name, + await this.loadPlug(newPlugMeta.name) + ); + } + // Page found, not deleted + deletedPlugs.delete(newPlugMeta.name); + + // Update in cache + this.plugMetaCache.set(newPlugMeta.name, newPlugMeta); + } + + for (const deletedPlug of deletedPlugs) { + this.plugMetaCache.delete(deletedPlug); + this.emit("plugUnloaded", deletedPlug); + } } - async writePage(name: string, text: string): Promise { - return this.wsCall("page.writePage", name, text); + private async loadPlugs(): Promise { + let res = await fetch(`${this.plugUrl}`, { + method: "GET", + }); + return (await res.json()) as PlugMeta[]; } - async deletePage(name: string): Promise { - return this.wsCall("page.deletePage", name); - } - - async getPageMeta(name: string): Promise { - return this.wsCall("page.getPageMeta", name); + private async loadPlug(name: string): Promise { + let res = await fetch(`${this.plugUrl}/${name}`, { + method: "GET", + }); + return (await res.json()) as Manifest; } } diff --git a/webapp/styles/main.scss b/webapp/styles/main.scss index 08ecda13..06da51a3 100644 --- a/webapp/styles/main.scss +++ b/webapp/styles/main.scss @@ -68,7 +68,16 @@ body { padding-left: 5px; padding-right: 10px; } + + .icon.saved { + color: #015701; + } + + .icon.unsaved { + color: #e19502; + } } + } #editor { diff --git a/webapp/syscalls/indexer.ts b/webapp/syscalls/indexer.ts index 08a64858..307fefe6 100644 --- a/webapp/syscalls/indexer.ts +++ b/webapp/syscalls/indexer.ts @@ -12,6 +12,6 @@ export default function indexerSyscalls(space: Space): SysCallMapping { "batchSet", "delete", ], - (name, ...args) => space.wsCall(`index.${name}`, ...args) + (ctx, name, ...args) => space.remoteSyscall(ctx.plug, `index.${name}`, args) ); } diff --git a/webapp/syscalls/space.ts b/webapp/syscalls/space.ts index 8a334e6d..20b35a1e 100644 --- a/webapp/syscalls/space.ts +++ b/webapp/syscalls/space.ts @@ -3,8 +3,8 @@ import { PageMeta } from "../types"; import { SysCallMapping } from "../../plugos/system"; export default (editor: Editor): SysCallMapping => ({ - listPages: (): PageMeta[] => { - return [...editor.viewState.allPages]; + listPages: async (): Promise => { + return [...(await editor.space.listPages())]; }, readPage: async ( ctx, diff --git a/webapp/syscalls/system.ts b/webapp/syscalls/system.ts index a695b148..4ca3fe76 100644 --- a/webapp/syscalls/system.ts +++ b/webapp/syscalls/system.ts @@ -7,7 +7,7 @@ export function systemSyscalls(space: Space): SysCallMapping { if (!ctx.plug) { throw Error("No plug associated with context"); } - return space.wsCall("invokeFunction", ctx.plug.name, name, ...args); + return space.remoteInvoke(ctx.plug, name, args); }, }; } diff --git a/webapp/types.ts b/webapp/types.ts index faa6af05..dd00418b 100644 --- a/webapp/types.ts +++ b/webapp/types.ts @@ -5,6 +5,7 @@ export type PageMeta = { lastModified: number; version?: number; lastOpened?: number; + created?: boolean; }; export const slashCommandRegexp = /\/[\w\-]*/; @@ -19,6 +20,7 @@ export type AppViewState = { currentPage?: string; showPageNavigator: boolean; showCommandPalette: boolean; + unsavedChanges: boolean; showRHS: boolean; rhsHTML: string; allPages: Set; @@ -29,6 +31,7 @@ export type AppViewState = { export const initialViewState: AppViewState = { showPageNavigator: false, showCommandPalette: false, + unsavedChanges: false, showRHS: false, rhsHTML: "

Loading...

", allPages: new Set(), @@ -39,6 +42,8 @@ export const initialViewState: AppViewState = { export type Action = | { type: "page-loaded"; name: string } | { type: "pages-listed"; pages: Set } + | { type: "page-changed" } + | { type: "page-saved" } | { type: "start-navigate" } | { type: "stop-navigate" } | { type: "update-commands"; commands: Map } diff --git a/webapp/watcher.ts b/webapp/watcher.ts index 35b8f24f..e69de29b 100644 --- a/webapp/watcher.ts +++ b/webapp/watcher.ts @@ -1,2 +0,0 @@ -import { Editor } from "./editor"; -import { safeRun } from "./util"; diff --git a/yarn.lock b/yarn.lock index 62b8dbfc..fae6535f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1403,16 +1403,6 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@socket.io/base64-arraybuffer@~1.0.2": - version "1.0.2" - resolved "https://registry.npmjs.org/@socket.io/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz" - integrity sha512-dOlCBKnDw4iShaIsH/bxujKTM18+2TOAsYz+KSc11Am38H4q5Xw8Bbz97ZYdrVNM+um3p7w86Bvvmcn9q+5+eQ== - -"@socket.io/component-emitter@~3.0.0": - version "3.0.0" - resolved "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.0.0.tgz" - integrity sha512-2pTGuibAXJswAPJjaKisthqS/NOK5ypG4LYT6tEAV0S/mxW0zOIvYvGK0V8w8+SHxAm6vRMSjqSalFXeBAqs+Q== - "@swc/helpers@^0.2.11": version "0.2.14" resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.2.14.tgz" @@ -1476,11 +1466,6 @@ "@types/connect" "*" "@types/node" "*" -"@types/component-emitter@^1.2.10": - version "1.2.11" - resolved "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.11.tgz" - integrity sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ== - "@types/connect@*": version "3.4.35" resolved "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz" @@ -1488,11 +1473,6 @@ dependencies: "@types/node" "*" -"@types/cookie@^0.4.1": - version "0.4.1" - resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz" - integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== - "@types/cookiejar@*": version "2.1.2" resolved "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz" @@ -1566,7 +1546,7 @@ resolved "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== -"@types/node@*", "@types/node@>=10.0.0", "@types/node@^17.0.21": +"@types/node@*", "@types/node@^17.0.21": version "17.0.21" resolved "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz" integrity sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ== @@ -1686,7 +1666,7 @@ abortcontroller-polyfill@^1.1.9: resolved "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz" integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q== -accepts@~1.3.4, accepts@~1.3.8: +accepts@~1.3.8: version "1.3.8" resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== @@ -1891,11 +1871,6 @@ babel-preset-jest@^27.5.1: babel-plugin-jest-hoist "^27.5.1" babel-preset-current-node-syntax "^1.0.0" -backo2@~1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz" - integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= - balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" @@ -1913,11 +1888,6 @@ base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64id@2.0.0, base64id@~2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz" - integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== - better-sqlite3@^7.5.0: version "7.5.0" resolved "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-7.5.0.tgz" @@ -2256,7 +2226,7 @@ commander@^8.3.0: resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== -component-emitter@^1.3.0, component-emitter@~1.3.0: +component-emitter@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -2307,7 +2277,7 @@ cookie-signature@1.0.6: resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= -cookie@0.4.2, cookie@~0.4.1: +cookie@0.4.2: version "0.4.2" resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== @@ -2322,7 +2292,7 @@ core-util-is@~1.0.0: resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cors@^2.8.5, cors@~2.8.5: +cors@^2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -2490,7 +2460,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@~4.3.1, debug@~4.3.2: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2701,44 +2671,6 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -engine.io-client@~6.1.1: - version "6.1.1" - resolved "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.1.1.tgz" - integrity sha512-V05mmDo4gjimYW+FGujoGmmmxRaDsrVr7AXA3ZIfa04MWM1jOfZfUwou0oNqhNwy/votUDvGDt4JA4QF4e0b4g== - dependencies: - "@socket.io/component-emitter" "~3.0.0" - debug "~4.3.1" - engine.io-parser "~5.0.0" - has-cors "1.1.0" - parseqs "0.0.6" - parseuri "0.0.6" - ws "~8.2.3" - xmlhttprequest-ssl "~2.0.0" - yeast "0.1.2" - -engine.io-parser@~5.0.0, engine.io-parser@~5.0.3: - version "5.0.3" - resolved "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.3.tgz" - integrity sha512-BtQxwF27XUNnSafQLvDi0dQ8s3i6VgzSoQMJacpIcGNrlUdfHSKbgm3jmjCVvQluGzqwujQMPAoMai3oYSTurg== - dependencies: - "@socket.io/base64-arraybuffer" "~1.0.2" - -engine.io@~6.1.0: - version "6.1.3" - resolved "https://registry.npmjs.org/engine.io/-/engine.io-6.1.3.tgz" - integrity sha512-rqs60YwkvWTLLnfazqgZqLa/aKo+9cueVfEi/dZ8PyGyaf8TLOxj++4QMIgeG3Gn0AhrWiFXvghsoY9L9h25GA== - dependencies: - "@types/cookie" "^0.4.1" - "@types/cors" "^2.8.12" - "@types/node" ">=10.0.0" - accepts "~1.3.4" - base64id "2.0.0" - cookie "~0.4.1" - cors "~2.8.5" - debug "~4.3.1" - engine.io-parser "~5.0.3" - ws "~8.2.3" - entities@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz" @@ -3197,11 +3129,6 @@ has-bigints@^1.0.1: resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== -has-cors@1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz" - integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk= - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz" @@ -4716,16 +4643,6 @@ parse5@6.0.1: resolved "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== -parseqs@0.0.6: - version "0.0.6" - resolved "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz" - integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w== - -parseuri@0.0.6: - version "0.0.6" - resolved "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz" - integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow== - parseurl@~1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz" @@ -5469,52 +5386,6 @@ slash@^3.0.0: resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -socket.io-adapter@~2.3.3: - version "2.3.3" - resolved "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.3.3.tgz" - integrity sha512-Qd/iwn3VskrpNO60BeRyCyr8ZWw9CPZyitW4AQwmRZ8zCiyDiL+znRnWX6tDHXnWn1sJrM1+b6Mn6wEDJJ4aYQ== - -socket.io-client@^4.4.1: - version "4.4.1" - resolved "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.4.1.tgz" - integrity sha512-N5C/L5fLNha5Ojd7Yeb/puKcPWWcoB/A09fEjjNsg91EDVr5twk/OEyO6VT9dlLSUNY85NpW6KBhVMvaLKQ3vQ== - dependencies: - "@socket.io/component-emitter" "~3.0.0" - backo2 "~1.0.2" - debug "~4.3.2" - engine.io-client "~6.1.1" - parseuri "0.0.6" - socket.io-parser "~4.1.1" - -socket.io-parser@~4.0.4: - version "4.0.4" - resolved "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz" - integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g== - dependencies: - "@types/component-emitter" "^1.2.10" - component-emitter "~1.3.0" - debug "~4.3.1" - -socket.io-parser@~4.1.1: - version "4.1.2" - resolved "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.1.2.tgz" - integrity sha512-j3kk71QLJuyQ/hh5F/L2t1goqzdTL0gvDzuhTuNSwihfuFUrcSji0qFZmJJPtG6Rmug153eOPsUizeirf1IIog== - dependencies: - "@socket.io/component-emitter" "~3.0.0" - debug "~4.3.1" - -socket.io@^4.4.1: - version "4.4.1" - resolved "https://registry.npmjs.org/socket.io/-/socket.io-4.4.1.tgz" - integrity sha512-s04vrBswdQBUmuWJuuNTmXUVJhP0cVky8bBDhdkf8y0Ptsu7fKU2LuLbts9g+pdmAdyMMn8F/9Mf1/wbtUN0fg== - dependencies: - accepts "~1.3.4" - base64id "~2.0.0" - debug "~4.3.2" - engine.io "~6.1.0" - socket.io-adapter "~2.3.3" - socket.io-parser "~4.0.4" - "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz" @@ -6183,11 +6054,6 @@ ws@^7.4.6: resolved "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz" integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A== -ws@~8.2.3: - version "8.2.3" - resolved "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz" - integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== - xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz" @@ -6203,11 +6069,6 @@ xmlchars@^2.2.0: resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xmlhttprequest-ssl@~2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz" - integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A== - xxhash-wasm@^0.4.2: version "0.4.2" resolved "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz" @@ -6263,8 +6124,3 @@ yargs@^17.3.1: string-width "^4.2.3" y18n "^5.0.5" yargs-parser "^21.0.0" - -yeast@0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz" - integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=