No More Collab. Fixes #449
* Fully removes real-time collaboration * URL scheme rewrite
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
import { safeRun } from "../../common/util.ts";
|
||||
import { Extension, HocuspocusProvider, Y, yCollab } from "../deps.ts";
|
||||
import { SyncService } from "../sync_service.ts";
|
||||
|
||||
const userColors = [
|
||||
{ color: "#30bced", light: "#30bced33" },
|
||||
{ color: "#6eeb83", light: "#6eeb8333" },
|
||||
{ color: "#ffbc42", light: "#ffbc4233" },
|
||||
{ color: "#ecd444", light: "#ecd44433" },
|
||||
{ color: "#ee6352", light: "#ee635233" },
|
||||
{ color: "#9ac2c9", light: "#9ac2c933" },
|
||||
{ color: "#8acb88", light: "#8acb8833" },
|
||||
{ color: "#1be7ff", light: "#1be7ff33" },
|
||||
];
|
||||
|
||||
export class CollabState {
|
||||
public ytext: Y.Text;
|
||||
collabProvider: HocuspocusProvider;
|
||||
private yundoManager: Y.UndoManager;
|
||||
interval?: number;
|
||||
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
readonly path: string,
|
||||
readonly token: string,
|
||||
username: string,
|
||||
private syncService: SyncService,
|
||||
public isLocalCollab: boolean,
|
||||
) {
|
||||
this.collabProvider = new HocuspocusProvider({
|
||||
url: serverUrl,
|
||||
name: token,
|
||||
|
||||
// Receive broadcasted messages from the server (right now only "page has been persisted" notifications)
|
||||
onStateless: (
|
||||
{ payload },
|
||||
) => {
|
||||
const message = JSON.parse(payload);
|
||||
switch (message.type) {
|
||||
case "persisted": {
|
||||
// Received remote persist notification, updating snapshot
|
||||
syncService.updateRemoteLastModified(
|
||||
message.path,
|
||||
message.lastModified,
|
||||
).catch(console.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.collabProvider.on("status", (e: any) => {
|
||||
console.log("Collab status change", e);
|
||||
});
|
||||
|
||||
this.ytext = this.collabProvider.document.getText("codemirror");
|
||||
this.yundoManager = new Y.UndoManager(this.ytext);
|
||||
|
||||
const randomColor =
|
||||
userColors[Math.floor(Math.random() * userColors.length)];
|
||||
|
||||
this.collabProvider.awareness.setLocalStateField("user", {
|
||||
name: username,
|
||||
color: randomColor.color,
|
||||
colorLight: randomColor.light,
|
||||
});
|
||||
if (isLocalCollab) {
|
||||
syncService.excludeFromSync(path).catch(console.error);
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
// Ping the store to make sure the file remains in exclusion
|
||||
syncService.excludeFromSync(path).catch(console.error);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
console.log("[COLLAB] Destroying collab provider");
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.collabProvider.destroy();
|
||||
// For whatever reason, destroy() doesn't properly clean up everything so we need to help a bit
|
||||
this.collabProvider.configuration.websocketProvider.webSocket = null;
|
||||
this.collabProvider.configuration.websocketProvider.destroy();
|
||||
|
||||
// When stopping collaboration, we're going back to sync mode. Make sure we got the latest and greatest remote timestamp to avoid
|
||||
// conflicts
|
||||
safeRun(async () => {
|
||||
await this.syncService.unExcludeFromSync(this.path);
|
||||
await this.syncService.fetchAndPersistRemoteLastModified(this.path);
|
||||
});
|
||||
}
|
||||
|
||||
collabExtension(): Extension {
|
||||
return yCollab(this.ytext, this.collabProvider.awareness, {
|
||||
undoManager: this.yundoManager,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ class IFrameWidget extends WidgetType {
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
console.log("toDOM");
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.srcdoc = panelHtml;
|
||||
// iframe.style.height = "0";
|
||||
|
||||
@@ -74,7 +74,7 @@ export function inlineImagesPlugin(editor: Editor) {
|
||||
let url = imageRexexResult.groups.url;
|
||||
const title = imageRexexResult.groups.title;
|
||||
if (url.indexOf("://") === -1) {
|
||||
url = `/.fs/${url}`;
|
||||
url = decodeURI(url);
|
||||
}
|
||||
widgets.push(
|
||||
Decoration.widget({
|
||||
|
||||
@@ -39,7 +39,7 @@ class TableViewWidget extends WidgetType {
|
||||
annotationPositions: true,
|
||||
translateUrls: (url) => {
|
||||
if (!url.includes("://")) {
|
||||
return `/.fs/${url}`;
|
||||
return `/${url}`;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { nanoid } from "https://esm.sh/nanoid@4.0.0";
|
||||
import type { Editor } from "./editor.tsx";
|
||||
|
||||
const collabPingInterval = 2500;
|
||||
|
||||
export class CollabManager {
|
||||
clientId = nanoid();
|
||||
localCollabServer: string;
|
||||
|
||||
constructor(private editor: Editor) {
|
||||
this.localCollabServer = location.protocol === "http:"
|
||||
? `ws://${location.host}/.ws-collab`
|
||||
: `wss://${location.host}/.ws-collab`;
|
||||
editor.eventHook.addLocalListener(
|
||||
"editor:pageLoaded",
|
||||
(pageName, previousPage) => {
|
||||
console.log("Page loaded", pageName, previousPage);
|
||||
this.updatePresence(pageName).catch(console.error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
start() {
|
||||
setInterval(() => {
|
||||
this.updatePresence(this.editor.currentPage!).catch(console.error);
|
||||
}, collabPingInterval);
|
||||
}
|
||||
|
||||
async updatePresence(currentPage: string) {
|
||||
try {
|
||||
// This is signaled through an OPTIONS call on the file we have open
|
||||
const resp = await this.editor.remoteSpacePrimitives.authenticatedFetch(
|
||||
`${this.editor.remoteSpacePrimitives.url}/${currentPage}.md`,
|
||||
{
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
"X-Client-Id": this.clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const collabId = resp.headers.get("X-Collab-Id");
|
||||
// Not reading body at all, is that a problem?
|
||||
|
||||
if (this.editor.collabState && !this.editor.collabState.isLocalCollab) {
|
||||
// We're in a remote collab mode, don't do anything
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("Collab ID", collabId);
|
||||
const previousCollabId = this.editor.collabState?.token.split("/")[0];
|
||||
if (!collabId && this.editor.collabState) {
|
||||
// Stop collab
|
||||
console.log("Stopping collab");
|
||||
if (this.editor.collabState.path === `${currentPage}.md`) {
|
||||
this.editor.flashNotification(
|
||||
"Other users have left this page, switched back to single-user mode.",
|
||||
);
|
||||
}
|
||||
this.editor.stopCollab();
|
||||
} else if (collabId && collabId !== previousCollabId) {
|
||||
// Start collab
|
||||
console.log("Starting collab");
|
||||
this.editor.flashNotification(
|
||||
"Opening page in multi-user mode.",
|
||||
);
|
||||
this.editor.startCollab(
|
||||
this.localCollabServer,
|
||||
`${collabId}/${currentPage}.md`,
|
||||
this.editor.getUsername(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// console.error("Ping error", e);
|
||||
if (
|
||||
e.message.toLowerCase().includes("failed") && this.editor.collabState
|
||||
) {
|
||||
console.log("Offline, stopping collab");
|
||||
this.editor.stopCollab();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,6 @@ export {
|
||||
Terminal as TerminalIcon,
|
||||
} from "https://esm.sh/preact-feather@4.2.1?external=preact";
|
||||
|
||||
// Y collab
|
||||
export * as Y from "yjs";
|
||||
export {
|
||||
yCollab,
|
||||
yUndoManagerKeymap,
|
||||
} from "https://esm.sh/y-codemirror.next@0.3.2?external=yjs,@codemirror/state,@codemirror/commands,@codemirror/history,@codemirror/view";
|
||||
export { HocuspocusProvider } from "https://esm.sh/@hocuspocus/provider@2.2.0?deps=lib0@0.2.70&external=yjs,ws&target=es2022";
|
||||
|
||||
// Vim mode
|
||||
export {
|
||||
getCM as vimGetCm,
|
||||
|
||||
+17
-73
@@ -68,7 +68,6 @@ import assetSyscalls from "../plugos/syscalls/asset.ts";
|
||||
import { eventSyscalls } from "../plugos/syscalls/event.ts";
|
||||
import { System } from "../plugos/system.ts";
|
||||
import { cleanModePlugins } from "./cm_plugins/clean.ts";
|
||||
import { CollabState } from "./cm_plugins/collab.ts";
|
||||
import {
|
||||
attachmentExtension,
|
||||
pasteLinkExtension,
|
||||
@@ -91,14 +90,12 @@ import {
|
||||
useEffect,
|
||||
useReducer,
|
||||
vim,
|
||||
yUndoManagerKeymap,
|
||||
} from "./deps.ts";
|
||||
import { AppCommand, CommandHook } from "./hooks/command.ts";
|
||||
import { SlashCommandHook } from "./hooks/slash_command.ts";
|
||||
import { PathPageNavigator } from "./navigator.ts";
|
||||
import reducer from "./reducer.ts";
|
||||
import customMarkdownStyle from "./style.ts";
|
||||
import { collabSyscalls } from "./syscalls/collab.ts";
|
||||
import { editorSyscalls } from "./syscalls/editor.ts";
|
||||
import { spaceSyscalls } from "./syscalls/space.ts";
|
||||
import { systemSyscalls } from "./syscalls/system.ts";
|
||||
@@ -137,7 +134,8 @@ import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
|
||||
import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primitives.ts";
|
||||
import { syncSyscalls } from "./syscalls/sync.ts";
|
||||
import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts";
|
||||
import { CollabManager } from "./collab_manager.ts";
|
||||
import { run } from "../plug-api/plugos-syscall/shell.ts";
|
||||
import { isValidPageName } from "$sb/lib/page.ts";
|
||||
|
||||
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
|
||||
|
||||
@@ -155,7 +153,6 @@ declare global {
|
||||
// Injected via index.html
|
||||
silverBulletConfig: {
|
||||
spaceFolderPath: string;
|
||||
syncEndpoint: string;
|
||||
};
|
||||
editor: Editor;
|
||||
}
|
||||
@@ -190,11 +187,9 @@ export class Editor {
|
||||
fullSyncCompleted = false;
|
||||
|
||||
// Runtime state (that doesn't make sense in viewState)
|
||||
collabState?: CollabState;
|
||||
syncService: SyncService;
|
||||
settings?: BuiltinSettings;
|
||||
kvStore: DexieKVStore;
|
||||
collabManager: CollabManager;
|
||||
|
||||
constructor(
|
||||
parent: Element,
|
||||
@@ -216,8 +211,6 @@ export class Editor {
|
||||
this.eventHook = new EventHook();
|
||||
system.addHook(this.eventHook);
|
||||
|
||||
this.collabManager = new CollabManager(this);
|
||||
|
||||
// Cron hook
|
||||
const cronHook = new CronHook(system);
|
||||
system.addHook(cronHook);
|
||||
@@ -237,7 +230,7 @@ export class Editor {
|
||||
|
||||
// Setup space
|
||||
this.remoteSpacePrimitives = new HttpSpacePrimitives(
|
||||
runtimeConfig.syncEndpoint,
|
||||
location.origin,
|
||||
runtimeConfig.spaceFolderPath,
|
||||
true,
|
||||
);
|
||||
@@ -328,7 +321,6 @@ export class Editor {
|
||||
systemSyscalls(this, this.system),
|
||||
markdownSyscalls(buildMarkdown(this.mdExtensions)),
|
||||
assetSyscalls(this.system),
|
||||
collabSyscalls(this),
|
||||
yamlSyscalls(),
|
||||
storeCalls,
|
||||
indexSyscalls,
|
||||
@@ -376,17 +368,12 @@ export class Editor {
|
||||
}
|
||||
});
|
||||
|
||||
// globalThis.addEventListener("beforeunload", (e) => {
|
||||
// console.log("Pinging with with undefined page name");
|
||||
// this.collabManager.updatePresence(undefined, this.currentPage);
|
||||
// });
|
||||
|
||||
this.eventHook.addLocalListener("plug:changed", async (fileName) => {
|
||||
console.log("Plug updated, reloading:", fileName);
|
||||
system.unload(fileName);
|
||||
await system.load(
|
||||
// await this.space.readFile(fileName, "utf8"),
|
||||
new URL(`/.fs/${fileName}`, location.href),
|
||||
new URL(`/${fileName}`, location.href),
|
||||
createSandbox,
|
||||
);
|
||||
this.plugsUpdated = true;
|
||||
@@ -402,7 +389,7 @@ export class Editor {
|
||||
|
||||
this.space.on({
|
||||
pageChanged: (meta) => {
|
||||
// Only reload when watching the current page (to avoid reloading when switching pages and in collab mode)
|
||||
// Only reload when watching the current page (to avoid reloading when switching pages)
|
||||
if (this.space.watchInterval && this.currentPage === meta.name) {
|
||||
console.log("Page changed elsewhere, reloading");
|
||||
this.flashNotification("Page changed elsewhere, reloading");
|
||||
@@ -485,7 +472,6 @@ export class Editor {
|
||||
|
||||
// Kick off background sync
|
||||
this.syncService.start();
|
||||
this.collabManager.start();
|
||||
|
||||
this.eventHook.addLocalListener("sync:success", async (operations) => {
|
||||
// console.log("Operations", operations);
|
||||
@@ -738,7 +724,7 @@ export class Editor {
|
||||
let touchCount = 0;
|
||||
|
||||
return EditorState.create({
|
||||
doc: this.collabState ? this.collabState.ytext.toString() : text,
|
||||
doc: text,
|
||||
extensions: [
|
||||
// Not using CM theming right now, but some extensions depend on the "dark" thing
|
||||
EditorView.theme({}, { dark: this.viewState.uiOptions.darkMode }),
|
||||
@@ -934,7 +920,6 @@ export class Editor {
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
...(this.collabState ? yUndoManagerKeymap : []),
|
||||
indentWithTab,
|
||||
...commandKeyBindings,
|
||||
{
|
||||
@@ -1057,7 +1042,6 @@ export class Editor {
|
||||
pasteLinkExtension,
|
||||
attachmentExtension(this),
|
||||
closeBrackets(),
|
||||
...[this.collabState ? this.collabState.collabExtension() : []],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1070,7 +1054,7 @@ export class Editor {
|
||||
await Promise.all((await this.space.listPlugs()).map(async (plugName) => {
|
||||
try {
|
||||
await this.system.load(
|
||||
new URL(`/.fs/${plugName}`, location.href),
|
||||
new URL(plugName, location.origin),
|
||||
createSandbox,
|
||||
);
|
||||
} catch (e: any) {
|
||||
@@ -1181,6 +1165,13 @@ export class Editor {
|
||||
name = this.settings!.indexPage;
|
||||
}
|
||||
|
||||
if (!isValidPageName(name)) {
|
||||
return this.flashNotification(
|
||||
"Invalid page name: page names cannot end with a file extension",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
|
||||
if (newWindow) {
|
||||
const win = window.open(`${location.origin}/${name}`, "_blank");
|
||||
if (win) {
|
||||
@@ -1206,10 +1197,6 @@ export class Editor {
|
||||
this.space.unwatchPage(previousPage);
|
||||
if (previousPage !== pageName) {
|
||||
await this.save(true);
|
||||
// And stop the collab session
|
||||
if (this.collabState) {
|
||||
this.stopCollab();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1222,6 +1209,9 @@ export class Editor {
|
||||
let doc;
|
||||
try {
|
||||
doc = await this.space.readPage(pageName);
|
||||
if (doc.meta.contentType.startsWith("text/html")) {
|
||||
throw new Error("Got HTML page, not markdown");
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Not found, new page
|
||||
console.log("Creating new page", pageName);
|
||||
@@ -1578,50 +1568,4 @@ export class Editor {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
startCollab(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
username: string,
|
||||
isLocalCollab = false,
|
||||
) {
|
||||
if (this.collabState) {
|
||||
// Clean up old collab state
|
||||
this.collabState.stop();
|
||||
}
|
||||
const initialText = this.editorView!.state.sliceDoc();
|
||||
this.collabState = new CollabState(
|
||||
serverUrl,
|
||||
`${this.currentPage!}.md`,
|
||||
token,
|
||||
username,
|
||||
this.syncService,
|
||||
isLocalCollab,
|
||||
);
|
||||
|
||||
this.collabState.collabProvider.on("synced", () => {
|
||||
if (this.collabState!.ytext.toString() === "") {
|
||||
console.log(
|
||||
"[Collab]",
|
||||
"Synced value is empty (new collab session), inserting local copy",
|
||||
);
|
||||
this.collabState!.ytext.insert(0, initialText);
|
||||
}
|
||||
});
|
||||
|
||||
this.rebuildEditorState();
|
||||
|
||||
// Don't watch for local changes in this mode
|
||||
this.space.unwatch();
|
||||
}
|
||||
|
||||
stopCollab() {
|
||||
if (this.collabState) {
|
||||
this.collabState.stop();
|
||||
this.collabState = undefined;
|
||||
this.rebuildEditorState();
|
||||
}
|
||||
// Start file watching again
|
||||
this.space.watch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,11 @@
|
||||
window.silverBulletConfig = {
|
||||
// These {{VARIABLES}} are replaced by http_server.ts
|
||||
spaceFolderPath: "{{SPACE_PATH}}",
|
||||
syncEndpoint: "{{SYNC_ENDPOINT}}",
|
||||
};
|
||||
// But in case these variables aren't replaced by the server, fall back fully static mode (no sync)
|
||||
if (window.silverBulletConfig.spaceFolderPath.includes("{{")) {
|
||||
window.silverBulletConfig = {
|
||||
spaceFolderPath: "",
|
||||
syncEndpoint: "/.fs"
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
+78
-73
@@ -24,42 +24,40 @@ const precacheFiles = Object.fromEntries([
|
||||
self.addEventListener("install", (event: any) => {
|
||||
console.log("[Service worker]", "Installing service worker...");
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log(
|
||||
"[Service worker]",
|
||||
"Now pre-caching client files",
|
||||
);
|
||||
return cache.addAll(Object.values(precacheFiles)).then(() => {
|
||||
console.log(
|
||||
"[Service worker]",
|
||||
Object.keys(precacheFiles).length,
|
||||
"client files cached",
|
||||
);
|
||||
// @ts-ignore: No need to wait
|
||||
self.skipWaiting();
|
||||
});
|
||||
}),
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
console.log(
|
||||
"[Service worker]",
|
||||
"Now pre-caching client files",
|
||||
);
|
||||
await cache.addAll(Object.values(precacheFiles));
|
||||
console.log(
|
||||
"[Service worker]",
|
||||
Object.keys(precacheFiles).length,
|
||||
"client files cached",
|
||||
);
|
||||
// @ts-ignore: No need to wait
|
||||
self.skipWaiting();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event: any) => {
|
||||
console.log("[Service worker]", "Activating new service worker!!!");
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
(async () => {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
console.log("[Service worker]", "Removing old cache", cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
}),
|
||||
).then(() => {
|
||||
// Let's activate ourselves for all existing clients
|
||||
// @ts-ignore: No need to wait, clients is a serviceworker thing
|
||||
return clients.claim();
|
||||
});
|
||||
}),
|
||||
);
|
||||
// @ts-ignore: No need to wait
|
||||
return clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -75,6 +73,15 @@ self.addEventListener("fetch", (event: any) => {
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const request = event.request;
|
||||
|
||||
// console.log("Getting request", request, [...request.headers.entries()]);
|
||||
|
||||
// Any request with the X-Sync-Mode header originates from the sync engine: pass it on to the server
|
||||
if (request.headers.has("x-sync-mode")) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// Try the static (client) file cache first
|
||||
const cachedResponse = await caches.match(cacheKey);
|
||||
// Return the cached response if found
|
||||
@@ -82,67 +89,65 @@ self.addEventListener("fetch", (event: any) => {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(event.request.url);
|
||||
|
||||
const requestUrl = new URL(request.url);
|
||||
const pathname = requestUrl.pathname;
|
||||
// console.log("In service worker, pathname is", pathname);
|
||||
// Are we fetching a URL from the same origin as the app? If not, we don't handle it here
|
||||
const fetchingLocal = location.host === requestUrl.host;
|
||||
|
||||
if (!fetchingLocal) {
|
||||
return fetch(event.request);
|
||||
// Are we fetching a URL from the same origin as the app? If not, we don't handle it and pass it on
|
||||
if (location.host !== requestUrl.host) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// If this is a /.fs request, this can either be a plug worker load or an attachment load
|
||||
if (pathname.startsWith("/.fs")) {
|
||||
if (!fileContentTable || event.request.headers.has("x-sync-mode")) {
|
||||
// Not initialzed yet, or explicitly in sync mode (so direct server communication requested)
|
||||
return fetch(event.request);
|
||||
}
|
||||
// console.log(
|
||||
// "Attempting to serve file from locally synced space:",
|
||||
// pathname,
|
||||
// );
|
||||
const path = decodeURIComponent(
|
||||
requestUrl.pathname.slice("/.fs/".length),
|
||||
);
|
||||
const data = await fileContentTable.get(path);
|
||||
if (data) {
|
||||
// console.log("Serving from space", path);
|
||||
if (!data.meta) {
|
||||
// Legacy database not fully synced yet
|
||||
data.meta = (await fileMetatable!.get(path))!;
|
||||
}
|
||||
return new Response(
|
||||
data.data,
|
||||
{
|
||||
headers: {
|
||||
"Content-type": data.meta.contentType,
|
||||
"Content-Length": "" + data.meta.size,
|
||||
"X-Permission": data.meta.perm,
|
||||
"X-Last-Modified": "" + data.meta.lastModified,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"Did not find file in locally synced space",
|
||||
path,
|
||||
);
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
// If this is a /*.* request, this can either be a plug worker load or an attachment load
|
||||
if (/\/.+\.[a-zA-Z]+$/.test(pathname)) {
|
||||
return handleLocalFileRequest(request, pathname);
|
||||
} else if (pathname === "/.auth") {
|
||||
return fetch(event.request);
|
||||
return fetch(request);
|
||||
} else {
|
||||
// Must be a page URL, let's serve index.html which will handle it
|
||||
return (await caches.match(precacheFiles["/"])) || fetch(event.request);
|
||||
return (await caches.match(precacheFiles["/"])) || fetch(request);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
async function handleLocalFileRequest(
|
||||
request: Request,
|
||||
pathname: string,
|
||||
): Promise<Response> {
|
||||
if (!fileContentTable) {
|
||||
// Not initialzed yet, or explicitly in sync mode (so direct server communication requested)
|
||||
return fetch(request);
|
||||
}
|
||||
const path = decodeURIComponent(pathname.slice(1));
|
||||
const data = await fileContentTable.get(path);
|
||||
if (data) {
|
||||
// console.log("Serving from space", path);
|
||||
if (!data.meta) {
|
||||
// Legacy database not fully synced yet
|
||||
data.meta = (await fileMetatable!.get(path))!;
|
||||
}
|
||||
return new Response(
|
||||
data.data,
|
||||
{
|
||||
headers: {
|
||||
"Content-type": data.meta.contentType,
|
||||
"Content-Length": "" + data.meta.size,
|
||||
"X-Permission": data.meta.perm,
|
||||
"X-Last-Modified": "" + data.meta.lastModified,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"Did not find file in locally synced space",
|
||||
path,
|
||||
);
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("message", (event: any) => {
|
||||
if (event.data.type === "flushCache") {
|
||||
caches.delete(CACHE_NAME)
|
||||
|
||||
+7
-129
@@ -17,7 +17,7 @@ const syncStartTimeKey = "syncStartTime";
|
||||
// Keeps the last time an activity was registered, used to detect if a sync is still alive and whether a new one should be started already
|
||||
const syncLastActivityKey = "syncLastActivity";
|
||||
|
||||
const syncExcludePrefix = "syncExclude:";
|
||||
const syncInitialFullSyncCompletedKey = "syncInitialFullSyncCompleted";
|
||||
|
||||
// maximum time between two activities before we consider a sync crashed
|
||||
const syncMaxIdleTimeout = 1000 * 20; // 20s
|
||||
@@ -56,17 +56,9 @@ export class SyncService {
|
||||
await this.syncFile(`${name}.md`);
|
||||
});
|
||||
|
||||
eventHook.addLocalListener("editor:pageSaved", async (name, meta) => {
|
||||
eventHook.addLocalListener("editor:pageSaved", async (name) => {
|
||||
const path = `${name}.md`;
|
||||
await this.syncFile(path);
|
||||
if (await this.isExcludedFromSync(path)) {
|
||||
// So we're editing a page and just saved it, but it's excluded from sync
|
||||
// Assumption: we're in collab mode for this file, so we're going to constantly update our local hash
|
||||
// console.log(
|
||||
// "Locally updating last modified in snapshot because we're in collab mode",
|
||||
// );
|
||||
await this.updateLocalLastModified(path, meta.lastModified);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,10 +78,9 @@ export class SyncService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async hasInitialSyncCompleted(): Promise<boolean> {
|
||||
hasInitialSyncCompleted(): Promise<boolean> {
|
||||
// Initial sync has happened when sync progress has been reported at least once, but the syncStartTime has been reset (which happens after sync finishes)
|
||||
return !(await this.kvStore.has(syncStartTimeKey)) &&
|
||||
(await this.kvStore.has(syncLastActivityKey));
|
||||
return this.kvStore.has(syncInitialFullSyncCompletedKey);
|
||||
}
|
||||
|
||||
async registerSyncStart(): Promise<void> {
|
||||
@@ -116,40 +107,7 @@ export class SyncService {
|
||||
async registerSyncStop(): Promise<void> {
|
||||
await this.registerSyncProgress();
|
||||
await this.kvStore.del(syncStartTimeKey);
|
||||
}
|
||||
|
||||
// Temporarily exclude a specific file from sync (e.g. when in collab mode)
|
||||
excludeFromSync(path: string): Promise<void> {
|
||||
return this.kvStore.set(syncExcludePrefix + path, Date.now());
|
||||
}
|
||||
|
||||
unExcludeFromSync(path: string): Promise<void> {
|
||||
return this.kvStore.del(syncExcludePrefix + path);
|
||||
}
|
||||
|
||||
async isExcludedFromSync(path: string): Promise<boolean> {
|
||||
const lastExcluded = await this.kvStore.get(syncExcludePrefix + path);
|
||||
return lastExcluded && Date.now() - lastExcluded < syncMaxIdleTimeout;
|
||||
}
|
||||
|
||||
async fetchAllExcludedFromSync(): Promise<string[]> {
|
||||
const entries = await this.kvStore.queryPrefix(syncExcludePrefix);
|
||||
const expiredPaths: string[] = [];
|
||||
const now = Date.now();
|
||||
const result = entries.filter(({ key, value }) => {
|
||||
if (now - value > syncMaxIdleTimeout) {
|
||||
expiredPaths.push(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map(({ key }) => key.slice(syncExcludePrefix.length));
|
||||
|
||||
if (expiredPaths.length > 0) {
|
||||
console.log("Purging expired sync exclusions: ", expiredPaths);
|
||||
await this.kvStore.batchDelete(expiredPaths);
|
||||
}
|
||||
|
||||
return result;
|
||||
await this.kvStore.set(syncInitialFullSyncCompletedKey, true);
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<Map<string, SyncStatusItem>> {
|
||||
@@ -167,83 +125,6 @@ export class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
// When in collab mode, we delegate the sync to the CDRT engine, to avoid conflicts, we try to keep the lastModified time in sync with the remote
|
||||
async updateRemoteLastModified(path: string, lastModified: number) {
|
||||
await this.noOngoingSync();
|
||||
await this.registerSyncStart();
|
||||
const snapshot = await this.getSnapshot();
|
||||
const entry = snapshot.get(path);
|
||||
if (entry) {
|
||||
snapshot.set(path, [entry[0], lastModified]);
|
||||
} else {
|
||||
// In the unlikely scenario that a space first openen on a collab page before every being synced
|
||||
try {
|
||||
console.log(
|
||||
"Received lastModified time for file not in snapshot",
|
||||
path,
|
||||
lastModified,
|
||||
);
|
||||
snapshot.set(path, [
|
||||
(await this.localSpacePrimitives.getFileMeta(path)).lastModified,
|
||||
lastModified,
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"Received lastModified time for non-existing file not in snapshot",
|
||||
path,
|
||||
lastModified,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.saveSnapshot(snapshot);
|
||||
await this.registerSyncStop();
|
||||
}
|
||||
|
||||
// Reach out out to remote space, fetch the latest lastModified time and update the local snapshot
|
||||
// This is used when exiting collab mode
|
||||
async fetchAndPersistRemoteLastModified(path: string) {
|
||||
const meta = await this.remoteSpace.getFileMeta(path);
|
||||
await this.updateRemoteLastModified(
|
||||
path,
|
||||
meta.lastModified,
|
||||
);
|
||||
}
|
||||
|
||||
// When in collab mode, we delegate the sync to the CDRT engine, to avoid conflicts, we try to keep the lastModified time in sync when local changes happen
|
||||
async updateLocalLastModified(path: string, lastModified: number) {
|
||||
await this.noOngoingSync();
|
||||
await this.registerSyncStart();
|
||||
const snapshot = await this.getSnapshot();
|
||||
const entry = snapshot.get(path);
|
||||
if (entry) {
|
||||
snapshot.set(path, [lastModified, entry[1]]);
|
||||
} else {
|
||||
// In the unlikely scenario that a space first openen on a collab page before every being synced
|
||||
try {
|
||||
console.log(
|
||||
"Setting lastModified time for file not in snapshot",
|
||||
path,
|
||||
lastModified,
|
||||
);
|
||||
snapshot.set(path, [
|
||||
lastModified,
|
||||
(await this.localSpacePrimitives.getFileMeta(path)).lastModified,
|
||||
]);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"Received lastModified time for non-existing file not in snapshot",
|
||||
path,
|
||||
lastModified,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.saveSnapshot(snapshot);
|
||||
await this.registerSyncStop();
|
||||
// console.log("All done!");
|
||||
}
|
||||
|
||||
start() {
|
||||
this.syncSpace().catch(
|
||||
console.error,
|
||||
@@ -271,14 +152,11 @@ export class SyncService {
|
||||
await this.registerSyncStart();
|
||||
let operations = 0;
|
||||
const snapshot = await this.getSnapshot();
|
||||
// Fetch the list of files that are excluded from sync (e.g. because they're in collab mode)
|
||||
const excludedFromSync = await this.fetchAllExcludedFromSync();
|
||||
// console.log("Excluded from sync", excludedFromSync);
|
||||
try {
|
||||
operations = await this.spaceSync!.syncFiles(
|
||||
snapshot,
|
||||
(path) =>
|
||||
this.isSyncCandidate(path) && !excludedFromSync.includes(path),
|
||||
(path) => this.isSyncCandidate(path),
|
||||
);
|
||||
this.eventHook.dispatchEvent("sync:success", operations);
|
||||
} catch (e: any) {
|
||||
@@ -295,7 +173,7 @@ export class SyncService {
|
||||
// console.log("Already syncing");
|
||||
return;
|
||||
}
|
||||
if (!this.isSyncCandidate(name) || (await this.isExcludedFromSync(name))) {
|
||||
if (!this.isSyncCandidate(name)) {
|
||||
return;
|
||||
}
|
||||
await this.registerSyncStart();
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { SysCallMapping } from "../../plugos/system.ts";
|
||||
import type { Editor } from "../editor.tsx";
|
||||
|
||||
export function collabSyscalls(editor: Editor): SysCallMapping {
|
||||
return {
|
||||
"collab.start": (
|
||||
_ctx,
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
username: string,
|
||||
) => {
|
||||
editor.startCollab(serverUrl, token, username);
|
||||
},
|
||||
"collab.stop": (
|
||||
_ctx,
|
||||
) => {
|
||||
editor.stopCollab();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function sandboxFetchSyscalls(
|
||||
return performLocalFetch(url, options);
|
||||
}
|
||||
const resp = httpSpacePrimitives.authenticatedFetch(
|
||||
httpSpacePrimitives.url,
|
||||
`${httpSpacePrimitives.url}/.rpc`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -14,7 +14,7 @@ export function shellSyscalls(
|
||||
throw new Error("Not supported in fully local mode");
|
||||
}
|
||||
const resp = httpSpacePrimitives.authenticatedFetch(
|
||||
httpSpacePrimitives.url,
|
||||
`${httpSpacePrimitives.url}/.rpc`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user