This commit is contained in:
Rizky 2023-10-14 21:56:56 +07:00
parent 6cbbe90348
commit 3a897090c6
16 changed files with 762 additions and 7 deletions

11
app/srv/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "srv",
"dependencies": {
"@node-rs/argon2": "^1.5.2",
"@paralleldrive/cuid2": "^2.2.2",
"@types/mime-types": "^2.1.2",
"lz-string": "^1.5.0",
"mime-types": "^2.1.35",
"radix3": "^1.1.0"
}
}

View File

@ -0,0 +1,87 @@
import { Websocket } from "hyper-express";
import { decompress } from "lz-string";
import * as Y from "yjs";
import { eg } from "../edit-global";
export const diffLocal = (ws: Websocket, msg: any) => {
return new Promise<void>((resolve) => {
const diff_local = Uint8Array.from(
decompress(msg.diff_local)
.split(",")
.map((x) => parseInt(x, 10))
);
let doc = null as unknown as Y.Doc;
let wss: Set<Websocket> = null as any;
let um: Y.UndoManager = null as any;
if (msg.mode === "page") {
doc = eg.edit.page[msg.id].doc as any;
wss = eg.edit.page[msg.id].ws;
um = eg.edit.page[msg.id].undoManager;
} else if (msg.mode === "comp") {
doc = eg.edit.comp[msg.id].doc as any;
wss = eg.edit.comp[msg.id].ws;
um = eg.edit.comp[msg.id].undoManager;
} else if (msg.mode === "site") {
doc = eg.edit.site[msg.id].doc as any;
wss = eg.edit.site[msg.id].ws;
um = eg.edit.site[msg.id].undoManager;
}
if (doc && wss) {
Y.applyUpdate(doc, diff_local);
if (msg.mode === "page") {
clearTimeout(eg.edit.page[msg.id].saveTimeout);
eg.edit.page[msg.id].saveTimeout = setTimeout(async () => {
if (msg.id) {
const page = eg.edit.page[msg.id].doc.getMap("map").toJSON();
try {
await db.page.update({
where: { id: page.id },
data: {
content_tree: page.content_tree,
updated_at: new Date(),
},
});
resolve();
} catch (e) {
console.error(e);
console.error({
...page,
updated_at: new Date(),
});
}
}
}, 1500);
} else if (msg.mode === "comp") {
eg.edit.comp[msg.id].saveTimeout = setTimeout(async () => {
const comp = eg.edit.comp[msg.id].doc.getMap("map").toJSON();
await db.component.update({
where: {
id: msg.id,
},
data: {
name: comp.name,
content_tree: comp.content_tree,
updated_at: new Date(),
},
});
}, 1500);
} else if (msg.mode === "site") {
clearTimeout(eg.edit.site[msg.id].saveTimeout);
eg.edit.site[msg.id].saveTimeout = setTimeout(async () => {
const site = eg.edit.site[msg.id].doc.getMap("site").toJSON();
delete site.page;
await db.site.update({
where: {
id: msg.id,
},
data: {
...site,
},
});
}, 1500);
}
}
});
};

View File

@ -0,0 +1,71 @@
import { Websocket } from "hyper-express";
import { compress } from "lz-string";
import { syncronize } from "y-pojo";
import * as Y from "yjs";
import {
WS_MSG_GET_COMP,
WS_MSG_SET_COMP,
WS_MSG_SV_LOCAL,
} from "../../../web/src/utils/types/ws";
import { SingleComp, eg } from "../edit-global";
export const getComp = async (ws: Websocket, msg: WS_MSG_GET_COMP) => {
const comp_id = msg.comp_id;
if (!eg.edit.comp[comp_id]) {
const rawComp = await db.component.findFirst({
where: {
id: comp_id,
},
});
if (!rawComp) {
const sent: WS_MSG_SET_COMP = {
type: "set_comp",
comp_id: comp_id,
changes: "",
};
ws.send(JSON.stringify(sent));
return;
}
if (rawComp) {
const ydoc = new Y.Doc() as SingleComp["doc"];
const map = ydoc.getMap("map");
syncronize(map as any, rawComp);
const ws = new Set<Websocket>();
const um = new Y.UndoManager(map, { ignoreRemoteMapChanges: true });
const broadcast = () => {
const sv_local = compress(Y.encodeStateVector(ydoc as any).toString());
const broadcast: WS_MSG_SV_LOCAL = {
type: "sv_local",
sv_local,
mode: "comp",
id: comp_id,
};
ws.forEach((w) => w.send(JSON.stringify(broadcast)));
};
um.on("stack-item-added", broadcast);
um.on("stack-item-updated", broadcast);
eg.edit.comp[comp_id] = {
doc: ydoc,
id: comp_id,
undoManager: um,
ws,
};
}
}
const comp = eg.edit.comp[comp_id];
if (comp) {
if (!comp.ws.has(ws)) comp.ws.add(ws);
const sent: WS_MSG_SET_COMP = {
type: "set_comp",
comp_id: comp_id,
changes: compress(Y.encodeStateAsUpdate(comp.doc as any).toString()),
};
ws.send(JSON.stringify(sent));
}
};

View File

@ -0,0 +1,74 @@
import { Websocket } from "hyper-express";
import { compress } from "lz-string";
import { syncronize } from "y-pojo";
import * as Y from "yjs";
import {
WS_MSG_GET_PAGE,
WS_MSG_SET_PAGE,
WS_MSG_SV_LOCAL,
} from "../../../web/src/utils/types/ws";
import { MPage } from "../../../web/src/utils/types/general";
import { eg } from "../edit-global";
import { loadPage } from "../tools/load-page";
import { validate } from "uuid";
export const getPage = async (ws: Websocket, msg: WS_MSG_GET_PAGE) => {
const page_id = msg.page_id;
if (!validate(page_id)) return;
if (!eg.edit.page[page_id]) {
const rawPage = await loadPage(page_id);
if (rawPage) {
const ydoc = new Y.Doc() as MPage;
let root = ydoc.getMap("map");
syncronize(root as any, rawPage);
const ws = new Set<Websocket>();
const um = new Y.UndoManager(root, { ignoreRemoteMapChanges: true });
const broadcast = () => {
const sv_local = compress(Y.encodeStateVector(ydoc as any).toString());
const broadcast: WS_MSG_SV_LOCAL = {
type: "sv_local",
sv_local,
mode: "page",
id: page_id,
};
ws.forEach((w) => w.send(JSON.stringify(broadcast)));
};
um.on("stack-item-added", broadcast);
um.on("stack-item-updated", broadcast);
eg.edit.page[page_id] = {
id: page_id,
doc: ydoc,
undoManager: um,
ws,
};
}
}
const page = eg.edit.page[page_id];
// let root = page.doc.getMap("map").get("content_tree") as unknown as MContent;
// if (root) {
// let changed = false;
// await page.doc.transact(async () => {
// changed = await validateTreePage(ws, root);
// });
// if (changed) {
// root = page.doc.getMap("map").get("content_tree") as unknown as MContent;
// await db.page.update({
// where: {
// id: page.id,
// },
// data: { content_tree: root.toJSON(), updated_at: new Date() },
// });
// }
// }
page.ws.add(ws);
const sent: WS_MSG_SET_PAGE = {
type: "set_page",
changes: compress(Y.encodeStateAsUpdate(page.doc as any).toString()),
};
ws.send(JSON.stringify(sent));
};

View File

@ -0,0 +1,48 @@
import { Websocket } from "hyper-express";
import { compress, decompress } from "lz-string";
import * as Y from "yjs";
import {
WS_MSG_SVDIFF_REMOTE,
WS_MSG_SV_LOCAL,
} from "../../../web/src/utils/types/ws";
import { eg } from "../edit-global";
import { getComp } from "./get-comp";
import { getPage } from "./get-page";
export const svLocal = async (ws: Websocket, msg: WS_MSG_SV_LOCAL) => {
const changes = Uint8Array.from(
decompress(msg.sv_local)
.split(",")
.map((x) => parseInt(x, 10))
);
let doc = null as any;
if (msg.mode === "page") {
if (!eg.edit.page[msg.id]) {
await getPage(ws, { type: "get_page", page_id: msg.id });
}
doc = eg.edit.page[msg.id].doc;
} else if (msg.mode === "comp") {
if (!eg.edit.comp[msg.id]) {
await getComp(ws, { comp_id: msg.id, type: "get_comp" });
}
doc = eg.edit.comp[msg.id].doc;
} else if (msg.mode === "site") {
doc = eg.edit.site[msg.id].doc;
}
if (doc) {
const diff_remote = Y.encodeStateAsUpdate(doc, changes);
const sv_remote = Y.encodeStateVector(doc);
const sendmsg: WS_MSG_SVDIFF_REMOTE = {
diff_remote: compress(diff_remote.toString()),
sv_remote: compress(sv_remote.toString()),
id: msg.id,
mode: msg.mode,
type: "svd_remote",
};
ws.send(JSON.stringify(sendmsg));
}
};

View File

@ -0,0 +1,46 @@
import { Websocket } from "hyper-express";
import { compress, decompress } from "lz-string";
import * as Y from "yjs";
import {
WS_MSG_DIFF_LOCAL,
WS_MSG_SVDIFF_REMOTE,
} from "../../../web/src/utils/types/ws";
import { eg } from "../edit-global";
export const svdiffRemote = async (
ws: Websocket,
msg: WS_MSG_SVDIFF_REMOTE
) => {
const sv_remote = Uint8Array.from(
decompress(msg.sv_remote)
.split(",")
.map((x) => parseInt(x, 10))
);
const diff_remote = Uint8Array.from(
decompress(msg.diff_remote)
.split(",")
.map((x) => parseInt(x, 10))
);
let doc = null as any;
if (msg.mode === "page") {
doc = eg.edit.page[msg.id].doc;
} else if (msg.mode === "comp") {
doc = eg.edit.comp[msg.id].doc;
} else if (msg.mode === "site") {
doc = eg.edit.site[msg.id].doc;
}
if (doc) {
const diff_local = Y.encodeStateAsUpdate(doc as any, sv_remote);
Y.applyUpdate(doc as any, diff_remote);
const sendmsg: WS_MSG_DIFF_LOCAL = {
type: "diff_local",
mode: msg.mode,
id: msg.id,
diff_local: compress(diff_local.toString()),
};
ws.send(JSON.stringify(sendmsg));
}
};

View File

@ -0,0 +1,37 @@
import { Websocket } from "hyper-express";
import { eg } from "../edit-global";
import { UndoManager } from "yjs";
import { WS_MSG_REDO, WS_MSG_UNDO } from "../../../web/src/utils/types/ws";
export const undo = (ws: Websocket, msg: WS_MSG_UNDO) => {
const um = getUndoManager(msg);
if (um && um.canUndo()) {
um.undo();
}
};
export const redo = (ws: Websocket, msg: WS_MSG_REDO) => {
const um = getUndoManager(msg);
if (um && um.canRedo()) {
um.redo();
}
};
const getUndoManager = (msg: WS_MSG_UNDO | WS_MSG_REDO) => {
let undoManager = null as null | UndoManager;
if (msg.mode === "page") {
if (eg.edit.page[msg.id]) {
undoManager = eg.edit.page[msg.id].undoManager;
}
} else if (msg.mode === "site") {
if (eg.edit.site[msg.id]) {
undoManager = eg.edit.site[msg.id].undoManager;
}
} else if (msg.mode === "comp") {
if (eg.edit.comp[msg.id]) {
undoManager = eg.edit.comp[msg.id].undoManager;
}
}
return undoManager;
};

View File

@ -0,0 +1,70 @@
import { ServerWebSocket } from "bun";
import { component } from "dbgen";
import { UndoManager } from "yjs";
import { TypedArray, TypedDoc, TypedMap } from "yjs-types";
import type { WSData } from "../../../../pkgs/core/server/create";
import { IItem } from "../../../web/src/utils/types/item";
import { IRoot } from "../../../web/src/utils/types/root";
import { Site } from "./tools/load-site";
import { MPage } from "../../../web/src/utils/types/general";
import type { RadixRouter } from "radix3";
type ArrayElement<ArrayType extends readonly unknown[]> =
ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
export type SingleComp = {
id: string;
doc: TypedDoc<{
map: TypedMap<component & { content_tree: TypedMap<IItem> }>;
}>;
undoManager: UndoManager;
saveTimeout?: ReturnType<typeof setTimeout>;
ws: Set<ServerWebSocket<WSData>>;
};
export const eg = global as unknown as {
cache: Record<
string,
Record<
string,
{
id: string;
js: string | null;
url: string;
js_compiled: string | null;
content_tree: IRoot;
lastRefresh: number;
}
>
>;
router: Record<string, RadixRouter<{ id: string; url: string }>>;
edit: {
site: Record<
string,
{
id: string;
doc: TypedDoc<{
site: TypedMap<
Site & { page: TypedArray<ArrayElement<Site["page"]>> }
>;
}>;
undoManager: UndoManager;
saveTimeout?: ReturnType<typeof setTimeout>;
ws: Set<ServerWebSocket<WSData>>;
}
>;
comp: Record<string, SingleComp>;
page: Record<
string,
{
id: string;
doc: MPage;
undoManager: UndoManager;
saveTimeout?: ReturnType<typeof setTimeout>;
ws: Set<ServerWebSocket<WSData>>;
}
>;
ws: WeakMap<ServerWebSocket<WSData>, { clientID: string }>;
};
};

View File

@ -0,0 +1,47 @@
import { eg } from "../edit-global";
// page cache timeout, in seconds
const PAGE_CACHE_TIMEOUT = 1;
export const loadCachedPage = async (site_id: string, page_id: string) => {
let site_cache = eg.cache[site_id];
if (!site_cache) {
eg.cache[site_id] = {};
site_cache = eg.cache[site_id];
}
if (!page_id) return {};
let cache = site_cache[page_id];
if (
!cache ||
(cache && !cache.lastRefresh) ||
(cache && Date.now() - cache.lastRefresh >= 1000 * PAGE_CACHE_TIMEOUT)
) {
if (eg.edit.page[page_id]) {
const edit = eg.edit.page[page_id].doc.getMap("map").toJSON();
edit.lastRefresh = Date.now();
site_cache[page_id] = edit as any;
} else {
const page = await db.page.findFirst({
where: { id: page_id },
select: {
js: true,
id: true,
url: true,
updated_at: true,
js_compiled: true,
content_tree: true,
},
});
if (page) {
site_cache[page_id] = {
...page,
lastRefresh: Date.now(),
} as any;
}
}
cache = site_cache[page_id];
}
return cache;
};

View File

@ -0,0 +1,22 @@
import { validate } from "uuid";
import { Page } from "../../../../web/src/utils/types/general";
export const loadPage = async (page_id: string) => {
if (page_id && validate(page_id)) {
let page = (await db.page.findFirst({
where: { id: page_id },
select: {
id: true,
js: true,
name: true,
id_site: true,
url: true,
js_compiled: true,
updated_at: true,
content_tree: true,
},
})) as unknown as null | Page;
return page;
}
return null;
};

View File

@ -0,0 +1,95 @@
import { page, site } from "dbgen";
import { validate as isValidUUID } from "uuid";
import * as Y from "yjs";
export type SiteConfig = {
api_url?: string;
prasi?: {
port: number;
dburl: string;
};
};
export type Site = Exclude<Awaited<ReturnType<typeof loadSite>>, null>;
export const loadSite = async (idOrDomain: string) => {
let rname = idOrDomain;
if (!rname) {
rname = "prasi.app";
}
const res = await db.site.findFirst({
where: isValidUUID(rname)
? {
id: rname,
}
: {
domain: rname,
},
include: {
page: {
select: {
id: true,
url: true,
updated_at: true,
name: true,
},
},
},
});
if (res) {
if (!res.page) {
res.page = [];
}
if (res.page.length === 0) {
const page = await createPage(res as any, {
name: "Home",
url: "/",
});
res.page.push(page);
}
for (const p of res.page) {
const page = p as any;
page.js = new Y.Text();
page.js_compiled = new Y.Text();
page.content_tree = new Y.Map();
}
}
return res as
| (Omit<site, "config"> & {
config?: SiteConfig;
page: {
id: string;
url: string;
updated_at: Date | null;
name: string;
}[];
})
| null;
};
const createPage = async (
site: site & { page: page[] },
page: WithOptional<
Parameters<typeof db.page.create>[0]["data"],
"content_tree"
>
) => {
const raw = await db.page.create({
data: {
...(page as any),
content_tree: page.content_tree ? page.content_tree : blank,
site: {
connect: { id: site.id },
},
},
});
return raw;
};
const blank = { id: "root", type: "root", childs: [] };
type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

View File

@ -0,0 +1,54 @@
import { Websocket } from "hyper-express";
import { syncronize } from "y-pojo";
import * as Y from "yjs";
import { fillID } from "../../../web/src/utils/page/tools/fill-id";
import { IContent, MContent } from "../../../web/src/utils/types/general";
import { IItem } from "../../../web/src/utils/types/item";
import { IRoot } from "../../../web/src/utils/types/root";
import { getComp } from "../action/get-comp";
import { eg } from "../edit-global";
const MAX_STRING_LENGTH = 15000;
export const validateTreeMap = async (
ws: Websocket,
item: MContent,
changed?: boolean
) => {
let _changed = changed;
const type = item.get("type") as IContent["type"] | "root";
if (type !== "root") {
item.forEach((val, key, map) => {
if (typeof val === "string") {
if (val.length > MAX_STRING_LENGTH) {
map.set(key, "");
_changed = true;
}
} else {
if (typeof val === "object" && val instanceof Y.Map) {
val._map.forEach((ival, ikey, imap) => {
if (typeof ival === "string") {
if ((ival as string).length > MAX_STRING_LENGTH) {
imap.set(ikey, "" as any);
_changed = true;
}
}
});
}
}
});
}
if (item) {
if (type !== "text") {
const childs = item.get("childs");
if (childs) {
for (const c of childs) {
if (await validateTreeMap(ws, c)) {
_changed = true;
}
}
}
}
}
return !!_changed;
};

View File

@ -1,10 +1,101 @@
import { createId } from "@paralleldrive/cuid2";
import { WebSocketHandler } from "bun"; import { WebSocketHandler } from "bun";
import { WSData } from "../../../pkgs/core/server/create";
import { WS_MSG } from "../../web/src/utils/types/ws";
import { eg } from "./edit/edit-global";
import { decompress } from "lz-string";
import { getPage } from "./edit/action/get-page";
import { getComp } from "./edit/action/get-comp";
import { svLocal } from "./edit/action/sv-local";
import { diffLocal } from "./edit/action/diff-local";
import { svdiffRemote } from "./edit/action/svdiff-remote";
import { redo, undo } from "./edit/action/undo-redo";
export const wsHandler: Record<string, WebSocketHandler<{ url: URL }>> = { eg.edit = {
site: {},
comp: {},
page: {},
ws: new WeakMap(),
};
const site = {
saveTimeout: null as any,
};
export const wsHandler: Record<string, WebSocketHandler<WSData>> = {
"/edit": { "/edit": {
open(ws) {}, open(ws) {
message(ws, message) {}, eg.edit.ws.set(ws, {
close(ws, code, reason) {}, clientID: createId(),
});
},
async message(ws, raw) {
if (typeof raw === "string") {
try {
const msg = JSON.parse(raw) as WS_MSG;
if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
switch (msg.type) {
case "site-js":
clearTimeout(site.saveTimeout);
site.saveTimeout = setTimeout(async () => {
const js = JSON.parse(decompress(msg.src));
await db.site.update({
where: {
id: msg.id_site,
},
data: {
js: js.src,
js_compiled: js.compiled,
},
});
}, 1000);
break;
case "get_page":
await getPage(ws, msg);
break;
case "get_comp":
await getComp(ws, msg);
break;
case "sv_local":
await svLocal(ws, msg);
break;
case "diff_local":
await diffLocal(ws, msg);
break;
case "svd_remote":
await svdiffRemote(ws, msg);
break;
case "undo":
undo(ws, msg);
break;
case "redo":
redo(ws, msg);
break;
}
} catch (e) {
console.log(e);
}
}
},
close(ws, code, reason) {
eg.edit.ws.delete(ws);
for (const page of Object.values(eg.edit.page)) {
page.ws.delete(ws);
}
for (const site of Object.values(eg.edit.site)) {
site.ws.delete(ws);
}
for (const comp of Object.values(eg.edit.comp)) {
comp.ws.delete(ws);
}
},
drain(ws) {}, drain(ws) {},
}, },
}; };

BIN
bun.lockb

Binary file not shown.

View File

@ -18,6 +18,5 @@
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, }
"dependencies": { "@node-rs/argon2": "^1.5.2", "@types/mime-types": "^2.1.2", "mime-types": "^2.1.35" }
} }

View File

@ -7,12 +7,15 @@ import { WebSocketHandler } from "bun";
const cache = { static: {} as Record<string, any> }; const cache = { static: {} as Record<string, any> };
export type WSData = { url: URL };
export const createServer = async () => { export const createServer = async () => {
g.api = {}; g.api = {};
g.router = createRouter({ strictTrailingSlash: true }); g.router = createRouter({ strictTrailingSlash: true });
g.server = Bun.serve({ g.server = Bun.serve({
port: g.port, port: g.port,
websocket: { websocket: {
maxPayloadLength: 99999999,
close(ws, code, reason) { close(ws, code, reason) {
const pathname = ws.data.url.pathname; const pathname = ws.data.url.pathname;
if (wsHandler[pathname]) { if (wsHandler[pathname]) {
@ -40,7 +43,7 @@ export const createServer = async () => {
} }
} }
}, },
} as WebSocketHandler<{ url: URL }>, } as WebSocketHandler<WSData>,
async fetch(req, server) { async fetch(req, server) {
if (g.status === "init") return new Response("initializing..."); if (g.status === "init") return new Response("initializing...");
const url = new URL(req.url); const url = new URL(req.url);