Removed all traces of sockets, real-time collab and other stuff.
parent
4525d60964
commit
859657f8b8
|
@ -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<void>) {
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
import { Editor } from "../../webapp/editor";
|
||||
import { Space } from "../../webapp/space";
|
||||
|
||||
declare namespace window {
|
||||
var ReactNativeWebView: any;
|
||||
var receiveMessage: any;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -9,6 +9,7 @@ export class Plug<HookT> {
|
|||
readonly runtimeEnv: RuntimeEnvironment;
|
||||
grantedPermissions: string[] = [];
|
||||
name: string;
|
||||
version: number;
|
||||
|
||||
constructor(
|
||||
system: System<HookT>,
|
||||
|
@ -19,6 +20,7 @@ export class Plug<HookT> {
|
|||
this.name = name;
|
||||
this.sandbox = sandboxFactory(this);
|
||||
this.runtimeEnv = system.runtimeEnv;
|
||||
this.version = new Date().getTime();
|
||||
}
|
||||
|
||||
async load(manifest: Manifest<HookT>) {
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import { SysCallMapping } from "../system";
|
||||
import { SyscallContext, SysCallMapping } from "../system";
|
||||
|
||||
export function transportSyscalls(
|
||||
names: string[],
|
||||
transportCall: (name: string, ...args: any[]) => Promise<any>
|
||||
transportCall: (
|
||||
ctx: SyscallContext,
|
||||
name: string,
|
||||
...args: any[]
|
||||
) => Promise<any>
|
||||
): SysCallMapping {
|
||||
let syscalls: SysCallMapping = {};
|
||||
|
||||
for (let name of names) {
|
||||
syscalls[name] = (ctx, ...args: any[]) => {
|
||||
return transportCall(name, ...args);
|
||||
return transportCall(ctx, name, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -14,8 +14,8 @@ export type SystemEvents<HookT> = {
|
|||
plugUnloaded: (name: string, plug: Plug<HookT>) => void;
|
||||
};
|
||||
|
||||
type SyscallContext = {
|
||||
plug: Plug<any> | null;
|
||||
export type SyscallContext = {
|
||||
plug: Plug<any>;
|
||||
};
|
||||
|
||||
type SyscallSignature = (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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<BackLink[]> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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<string, Task[]>();
|
||||
for (let {
|
||||
key,
|
||||
|
|
|
@ -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<any> {
|
||||
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<SilverBulletHooks>("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);
|
||||
});
|
||||
});
|
|
@ -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<string>();
|
||||
|
||||
constructor(readonly sock: Socket) {}
|
||||
}
|
||||
|
||||
export interface ApiProvider {
|
||||
init(): Promise<void>;
|
||||
|
||||
api(): Object;
|
||||
}
|
||||
|
||||
export class SocketServer {
|
||||
private openPages = new Map<string, Page>();
|
||||
private connectedSockets = new Set<Socket>();
|
||||
private apis = new Map<string, ApiProvider>();
|
||||
readonly rootPath: string;
|
||||
private serverSocket: Server;
|
||||
system: System<SilverBulletHooks>;
|
||||
|
||||
constructor(
|
||||
rootPath: string,
|
||||
serverSocket: Server,
|
||||
system: System<SilverBulletHooks>
|
||||
) {
|
||||
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<any>
|
||||
) => {
|
||||
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<any> => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<PageMeta[]>;
|
||||
|
||||
readPage(pageName: string): Promise<{ text: string; meta: PageMeta }>;
|
||||
|
||||
writePage(pageName: string, text: string): Promise<PageMeta>;
|
||||
|
||||
getPageMeta(pageName: string): Promise<PageMeta>;
|
||||
|
||||
deletePage(pageName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class EventedStorage implements Storage {
|
||||
constructor(private wrapped: Storage, private eventHook: EventHook) {}
|
||||
|
||||
listPages(): Promise<PageMeta[]> {
|
||||
return this.wrapped.listPages();
|
||||
}
|
||||
|
||||
readPage(pageName: string): Promise<{ text: string; meta: PageMeta }> {
|
||||
return this.wrapped.readPage(pageName);
|
||||
}
|
||||
|
||||
async writePage(pageName: string, text: string): Promise<PageMeta> {
|
||||
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<PageMeta> {
|
||||
return this.wrapped.getPageMeta(pageName);
|
||||
}
|
||||
|
||||
async deletePage(pageName: string): Promise<void> {
|
||||
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<void> {
|
||||
let localPath = path.join(this.rootPath, pageName + ".md");
|
||||
await unlink(localPath);
|
||||
}
|
||||
|
|
|
@ -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<SilverBulletHooks>;
|
||||
private rootPath: string;
|
||||
private storage: Storage;
|
||||
private distDir: string;
|
||||
private eventHook: EventHook;
|
||||
private db: Knex<any, unknown[]>;
|
||||
|
||||
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() {}
|
||||
}
|
||||
|
|
|
@ -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<any, unknown>;
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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<string, Page>;
|
||||
pageStore: DiskStorage;
|
||||
rootPath: string;
|
||||
connectedSockets: Set<Socket>;
|
||||
private system: System<SilverBulletHooks>;
|
||||
private eventHook: EventHook;
|
||||
|
||||
constructor(
|
||||
rootPath: string,
|
||||
connectedSockets: Set<Socket>,
|
||||
openPages: Map<string, Page>,
|
||||
system: System<SilverBulletHooks>
|
||||
) {
|
||||
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<void> {
|
||||
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<boolean> => {
|
||||
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<Update[]> => {
|
||||
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<PageMeta[]> => {
|
||||
return this.pageStore.listPages();
|
||||
},
|
||||
|
||||
getPageMeta: async (
|
||||
clientConn: ClientConnection,
|
||||
pageName: string
|
||||
): Promise<PageMeta> => {
|
||||
let page = this.openPages.get(pageName);
|
||||
if (page) {
|
||||
return page.meta;
|
||||
}
|
||||
return this.pageStore.getPageMeta(pageName);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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<SilverBulletHooks>("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()
|
||||
|
|
|
@ -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<PageMeta[]> => {
|
||||
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<PageMeta> => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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<string, Cursor>();
|
||||
clientStates = new Set<ClientPageState>();
|
||||
|
||||
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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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<any>[];
|
||||
/// 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<any>[],
|
||||
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<any>[];
|
||||
};
|
||||
|
||||
const collabConfig = Facet.define<
|
||||
CollabConfig & { generatedID: string },
|
||||
Required<CollabConfig>
|
||||
>({
|
||||
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<CollabState>();
|
||||
|
||||
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;
|
||||
}
|
248
webapp/collab.ts
248
webapp/collab.ts
|
@ -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<string, Cursor>;
|
||||
|
||||
constructor(text: Text, version: number, cursors: Map<string, Cursor>) {
|
||||
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<string, Cursor>) => void;
|
||||
};
|
||||
|
||||
export function collabExtension(
|
||||
pageName: string,
|
||||
clientID: string,
|
||||
doc: CollabDocument,
|
||||
collabEmitter: EventEmitter<CollabEvents>,
|
||||
callbacks: {
|
||||
pushUpdates: (
|
||||
pageName: string,
|
||||
version: number,
|
||||
updates: readonly (Update & { origin: Transaction })[]
|
||||
) => Promise<boolean>;
|
||||
pullUpdates: (
|
||||
pageName: string,
|
||||
version: number
|
||||
) => Promise<readonly Update[]>;
|
||||
reload: () => void;
|
||||
}
|
||||
) {
|
||||
let plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private pushing = false;
|
||||
private done = false;
|
||||
private failedPushes = 0;
|
||||
private cursorPositions: Map<string, Cursor> = doc.cursors;
|
||||
decorations: DecorationSet;
|
||||
|
||||
throttledPush = throttle(() => this.push(), throttleInterval);
|
||||
|
||||
eventHandlers: Partial<CollabEvents> = {
|
||||
cursorSnapshot: (pageName, cursors) => {
|
||||
console.log("Received new cursor snapshot", cursors);
|
||||
this.cursorPositions = new Map(Object.entries(cursors));
|
||||
},
|
||||
};
|
||||
|
||||
buildDecorations(view: EditorView) {
|
||||
let builder = new RangeSetBuilder<Decoration>();
|
||||
|
||||
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<string>();
|
||||
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,
|
||||
];
|
||||
}
|
|
@ -81,8 +81,11 @@ export function FilterList({
|
|||
|
||||
let selectedElementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filter = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const originalPhrase = e.target.value;
|
||||
function filterUpdate(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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) {
|
||||
|
|
|
@ -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 (
|
||||
<div id="top" onClick={onClick}>
|
||||
<div className="inner">
|
||||
<span className="icon">
|
||||
<span className={`icon ${unsavedChanges ? "unsaved" : "saved"}`}>
|
||||
<FontAwesomeIcon icon={faFileLines} />
|
||||
</span>
|
||||
<span className="current-page">{prettyName(pageName)}</span>
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import { StateEffect } from "@codemirror/state";
|
||||
export type Cursor = {
|
||||
pos: number;
|
||||
userId: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const cursorEffect = StateEffect.define<Cursor>({
|
||||
map({ pos, userId, color }, changes) {
|
||||
return { pos: changes.mapPos(pos), userId, color };
|
||||
},
|
||||
});
|
|
@ -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<SilverBulletHooks>("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<string, Cursor>())
|
||||
),
|
||||
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<void> {
|
||||
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 {
|
|||
<TopBar
|
||||
pageName={viewState.currentPage}
|
||||
notifications={viewState.notifications}
|
||||
unsavedChanges={viewState.unsavedChanges}
|
||||
onClick={() => {
|
||||
dispatch({ type: "start-navigate" });
|
||||
}}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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: "<!--", close: "-->"}})
|
||||
const data = defineLanguageFacet({ block: { open: "<!--", close: "-->" } });
|
||||
|
||||
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());
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
351
webapp/space.ts
351
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<PageMeta>) => void;
|
||||
loadSystem: (systemJSON: SystemJSON<any>) => 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<SpaceEvents> {
|
||||
socket: Socket;
|
||||
reqId = 0;
|
||||
allPages = new Set<PageMeta>();
|
||||
pageUrl: string;
|
||||
pageMetaCache = new Map<string, PageMeta>();
|
||||
plugMetaCache = new Map<string, PlugMeta>();
|
||||
watchedPages = new Set<string>();
|
||||
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<string>(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<number, string>();
|
||||
public wsCall(eventName: string, ...args: any[]): Promise<any> {
|
||||
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<Set<PageMeta>> {
|
||||
// this.updatePageListAsync();
|
||||
return new Set([...this.pageMetaCache.values()]);
|
||||
}
|
||||
|
||||
async pushUpdates(
|
||||
pageName: string,
|
||||
version: number,
|
||||
fullUpdates: readonly (Update & { origin: Transaction })[]
|
||||
): Promise<boolean> {
|
||||
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<PageMeta> {
|
||||
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<readonly Update[]> {
|
||||
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<void> {
|
||||
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<PageMeta[]> {
|
||||
return Array.from(this.allPages);
|
||||
private async getPageMeta(name: string): Promise<PageMeta> {
|
||||
let res = await fetch(`${this.pageUrl}/${name}`, {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
return this.responseToMetaCacher(name, res);
|
||||
}
|
||||
|
||||
async openPage(name: string): Promise<CollabDocument> {
|
||||
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<any>,
|
||||
name: string,
|
||||
args: any[]
|
||||
): Promise<any> {
|
||||
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<void> {
|
||||
this.socket.emit("page.closePage", name);
|
||||
async remoteInvoke(plug: Plug<any>, name: string, args: any[]): Promise<any> {
|
||||
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<void> {
|
||||
const newPlugs = await this.loadPlugs();
|
||||
let deletedPlugs = new Set<string>(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<PageMeta> {
|
||||
return this.wsCall("page.writePage", name, text);
|
||||
private async loadPlugs(): Promise<PlugMeta[]> {
|
||||
let res = await fetch(`${this.plugUrl}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return (await res.json()) as PlugMeta[];
|
||||
}
|
||||
|
||||
async deletePage(name: string): Promise<void> {
|
||||
return this.wsCall("page.deletePage", name);
|
||||
}
|
||||
|
||||
async getPageMeta(name: string): Promise<PageMeta> {
|
||||
return this.wsCall("page.getPageMeta", name);
|
||||
private async loadPlug(name: string): Promise<Manifest> {
|
||||
let res = await fetch(`${this.plugUrl}/${name}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return (await res.json()) as Manifest;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -68,7 +68,16 @@ body {
|
|||
padding-left: 5px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.icon.saved {
|
||||
color: #015701;
|
||||
}
|
||||
|
||||
.icon.unsaved {
|
||||
color: #e19502;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#editor {
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<PageMeta[]> => {
|
||||
return [...(await editor.space.listPages())];
|
||||
},
|
||||
readPage: async (
|
||||
ctx,
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<PageMeta>;
|
||||
|
@ -29,6 +31,7 @@ export type AppViewState = {
|
|||
export const initialViewState: AppViewState = {
|
||||
showPageNavigator: false,
|
||||
showCommandPalette: false,
|
||||
unsavedChanges: false,
|
||||
showRHS: false,
|
||||
rhsHTML: "<h1>Loading...</h1>",
|
||||
allPages: new Set(),
|
||||
|
@ -39,6 +42,8 @@ export const initialViewState: AppViewState = {
|
|||
export type Action =
|
||||
| { type: "page-loaded"; name: string }
|
||||
| { type: "pages-listed"; pages: Set<PageMeta> }
|
||||
| { type: "page-changed" }
|
||||
| { type: "page-saved" }
|
||||
| { type: "start-navigate" }
|
||||
| { type: "stop-navigate" }
|
||||
| { type: "update-commands"; commands: Map<string, AppCommand> }
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
import { Editor } from "./editor";
|
||||
import { safeRun } from "./util";
|
156
yarn.lock
156
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=
|
||||
|
|
Loading…
Reference in New Issue