diff --git a/app/srv/ws/sync/actions-def.ts b/app/srv/ws/sync/actions-def.ts new file mode 100644 index 00000000..7df0a90e --- /dev/null +++ b/app/srv/ws/sync/actions-def.ts @@ -0,0 +1,26 @@ +export const SyncActionDefinition = { + "site": { + "all": "0", + "group": "1", + "load": "2" + }, + "comp": { + "all": "3", + "group": "4", + "doc": "5" + }, + "page": { + "all": "6", + "load": "7" + } +}; +export const SyncActionPaths = { + "0": "site.all", + "1": "site.group", + "2": "site.load", + "3": "comp.all", + "4": "comp.group", + "5": "comp.doc", + "6": "page.all", + "7": "page.load" +}; \ No newline at end of file diff --git a/app/srv/ws/sync/actions.ts b/app/srv/ws/sync/actions.ts new file mode 100644 index 00000000..446f555f --- /dev/null +++ b/app/srv/ws/sync/actions.ts @@ -0,0 +1,22 @@ +import { component, site, page } from "dbgen"; + +export const SyncActions = { + site: { + all: () => + ({}) as Promise< + Record + >, + group: () => ({}) as Promise>, + load: (id: string) => ({}) as Promise, + }, + comp: { + all: () => ({}) as Record>, + group: () => ({}) as Record, + doc: (id: string) => ({}) as Uint8Array, + }, + page: { + all: (id_site: string) => + ({}) as Record>, + load: (id: string) => ({}) as Uint8Array, + }, +}; diff --git a/app/srv/ws/sync/sync-handler.ts b/app/srv/ws/sync/sync-handler.ts index cb71bca7..fb662832 100644 --- a/app/srv/ws/sync/sync-handler.ts +++ b/app/srv/ws/sync/sync-handler.ts @@ -1,13 +1,13 @@ -import { ServerWebSocket, WebSocketHandler } from "bun"; -import { WSData } from "../../../../pkgs/core/server/create"; -import { Packr } from "msgpackr"; import { createId } from "@paralleldrive/cuid2"; -import { MSG_TO_SERVER } from "./type"; +import { ServerWebSocket, WebSocketHandler } from "bun"; +import { Packr } from "msgpackr"; +import { WSData } from "../../../../pkgs/core/server/create"; const packr = new Packr({ structuredClone: true }); const conns = new Map< string, { + user_id: string; ws: ServerWebSocket; msg: { pending: Record>; @@ -18,21 +18,31 @@ const conns = new Map< const wconns = new WeakMap, string>(); export const syncHandler: WebSocketHandler = { open(ws) { - const id = createId(); - conns.set(id, { ws, msg: { pending: {}, resolve: {} } }); - wconns.set(ws, id); - ws.sendBinary(packr.pack({ type: "identify", id })); + const client_id = createId(); + conns.set(client_id, { + user_id: "", + ws, + msg: { pending: {}, resolve: {} }, + }); + wconns.set(ws, client_id); + ws.sendBinary(packr.pack({ type: "client_id", client_id })); + }, + close(ws, code, reason) { + const conn_id = wconns.get(ws); + if (conn_id) { + conns.delete(conn_id); + wconns.delete(ws); + } }, message(ws, raw) { const conn_id = wconns.get(ws); if (conn_id) { const conn = conns.get(conn_id); if (conn) { - const msg = packr.unpack(Buffer.from(raw)) as MSG_TO_SERVER & { - msg_client_id: string; - }; - - switch (msg.action) { + const msg = packr.unpack(Buffer.from(raw)); + if (msg.type === "user_id") { + const { user_id } = msg; + conn.user_id = user_id; } } } diff --git a/app/srv/ws/sync/type.ts b/app/srv/ws/sync/type.ts deleted file mode 100644 index 6f2f9adb..00000000 --- a/app/srv/ws/sync/type.ts +++ /dev/null @@ -1,23 +0,0 @@ -export enum DType { - Site, - Comp, - Page, -} - -export enum ServerAction { - Load, -} - -export type MSG_TO_SERVER = { - action: ServerAction.Load; - type: DType; - id: string; -}; - -export enum ClientAction { - Identify, -} -export type MSG_TO_CLIENT = { - action: ClientAction.Identify; - id: string; -}; diff --git a/app/web/package.json b/app/web/package.json index dcdb37c7..b8ae8980 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -13,13 +13,14 @@ "@paralleldrive/cuid2": "2.2.2", "@parcel/packager-wasm": "^2.10.0", "@parcel/service-worker": "^2.10.0", - "msgpackr": "^1.9.9", + "@qiwi/deep-proxy": "^2.0.3", "@swc/wasm-web": "1.3.94-nightly-20231014.1", "algoliasearch": "^4.20.0", "date-fns": "^2.30.0", "dbgen": "workspace:*", "downshift": "^8.2.2", "esbuild-wasm": "^0.19.4", + "hash-wasm": "^4.10.0", "idb-keyval": "^6.2.1", "js-base64": "^3.7.5", "lodash.capitalize": "^4.2.1", @@ -38,6 +39,7 @@ "lodash.uniq": "^4.5.0", "lodash.uniqby": "^4.7.0", "monaco-jsx-syntax-highlight-v2": "^1.2.2", + "msgpackr": "^1.9.9", "polywasm": "^0.1.4", "prettier": "3.0.3", "prop-types": "^15.8.1", @@ -50,6 +52,7 @@ "react-is": "^18.2.0", "react-use-error-boundary": "^3.0.0", "react-virtuoso": "^4.6.1", + "safe-stable-stringify": "^2.4.3", "svgo": "^3.0.2", "textdiff-create": "^1.1.9", "tinycolor2": "^1.6.0", diff --git a/app/web/src/base/page/ned.tsx b/app/web/src/base/page/ned.tsx new file mode 100644 index 00000000..e907ac5d --- /dev/null +++ b/app/web/src/base/page/ned.tsx @@ -0,0 +1,29 @@ +import { page, useGlobal } from "web-utils"; +import { EditorGlobal } from "../../render/editor/logic/global"; +import { Loading } from "../../utils/ui/loading"; +import { clientStartSync } from "../../utils/sync/client"; + +export default page({ + url: "/ned/:site_id/:page_id", + component: ({}) => { + const p = useGlobal(EditorGlobal, "EDITOR"); + + const session = JSON.parse( + localStorage.getItem("prasi-session") || "null" + ) as { data: { user: { id: string } } }; + if (!session) { + navigate("/login"); + return ; + } + + if (!p.sync) { + // p.sync = clientStartSync({ + // user_id: session.data.user.id, + // }); + + return ; + } + + return
; + }, +}); diff --git a/app/web/src/base/pages.ts b/app/web/src/base/pages.ts index 40113090..14a81af7 100644 --- a/app/web/src/base/pages.ts +++ b/app/web/src/base/pages.ts @@ -26,3 +26,7 @@ export const live = { url: "/live/:domain/**", page: () => import("./page/live"), }; +export const ned = { + url: "/ned/:site_id/:page_id", + page: () => import("./page/ned"), +}; diff --git a/app/web/src/index.tsx b/app/web/src/index.tsx index 8217d661..b2fec886 100644 --- a/app/web/src/index.tsx +++ b/app/web/src/index.tsx @@ -1,4 +1,4 @@ -import { createRoot } from "react-dom/client"; +import { Root as ReactRoot, createRoot } from "react-dom/client"; import { defineReact, defineWindow } from "web-utils"; import { Root } from "./base/root"; import "./index.css"; @@ -6,35 +6,107 @@ import { createAPI, createDB, reloadDBAPI } from "./utils/script/init-api"; import { w } from "./utils/types/general"; const start = async () => { + const base = `${location.protocol}//${location.host}`; + let react = { + root: null as null | ReactRoot, + }; if (!["localhost", "127.0.0.1"].includes(location.hostname)) { const sw = await registerServiceWorker(); navigator.serviceWorker.addEventListener("message", (e) => { - if (e.data.type === "activated") { - if (e.data.shouldRefresh && sw) { - sw.unregister().then(() => { - window.location.reload(); - }); - } - } - if (e.data.type === "ready") { - const sw = navigator.serviceWorker.controller; - - if (sw) { - const routes = Object.entries(w.prasiApi[base].apiEntry).map( - ([k, v]: any) => ({ - url: v.url, - name: k, - }) + if (react.root) { + if (e.data.type === "offline") { + w.offline = true; + const click = () => { + if (react.root) react.root.render(); + }; + setTimeout(click, 5000); + react.root.render( + <> + +
+
+ Network Failed: Offline Mode +
+
+ ); + } - sw.postMessage({ - type: "add-cache", - url: location.href, - }); - sw.postMessage({ - type: "define-route", - routes, - }); + if (e.data.type === "activated") { + if (e.data.shouldRefresh && sw) { + react.root.render( + <> + +
+
+ Updating App... +
+
+ + ); + + sw.unregister().then(() => { + window.location.reload(); + }); + } else { + const localVersion = localStorage.getItem("prasi-version"); + if (localVersion !== e.data.version) { + localStorage.setItem("prasi-version", e.data.version); + const click = () => { + if (react.root) react.root.render(); + }; + setTimeout(click, 5000); + react.root.render( + <> + +
+
+ App Updated, Ready to use offline +
+
+ + ); + } + } } } }); @@ -48,15 +120,36 @@ const start = async () => { defineReact(); await defineWindow(false); - const base = `${location.protocol}//${location.host}`; w.serverurl = base; await reloadDBAPI(base, "prod"); + + const swc = navigator.serviceWorker.controller; + if (swc) { + swc.postMessage({ + type: "add-cache", + url: location.href, + }); + if (w.prasiApi && w.prasiApi[base] && w.prasiApi[base].apiEntry) { + const routes = Object.entries(w.prasiApi[base].apiEntry).map( + ([k, v]: any) => ({ + url: v.url, + name: k, + }) + ); + + swc.postMessage({ + type: "define-route", + routes, + }); + } + } w.api = createAPI(base); w.db = createDB(base); const el = document.getElementById("root"); if (el) { - createRoot(el).render(); + react.root = createRoot(el); + react.root.render(); } }; diff --git a/app/web/src/render/editor/logic/global.ts b/app/web/src/render/editor/logic/global.ts index dce6caf1..6b028a4e 100644 --- a/app/web/src/render/editor/logic/global.ts +++ b/app/web/src/render/editor/logic/global.ts @@ -9,6 +9,7 @@ import { IRoot } from "../../../utils/types/root"; import { LSite } from "../../live/logic/global"; import { ISection } from "../../../utils/types/section"; import { IText } from "../../../utils/types/text"; +import { clientStartSync } from "../../../utils/sync/client"; export type WithRequired = T & { [P in K]-?: T[P] }; export type NodeMeta = { meta: ItemMeta; idx: number }; @@ -40,6 +41,7 @@ export type ItemMeta = { export const EditorGlobal = { /** ui */ mode: "" as "desktop" | "mobile", + sync: null as unknown as ReturnType, status: "init" as | "init" | "loading" diff --git a/app/web/src/render/editor/panel/script/monaco/monaco-el-history.tsx b/app/web/src/render/editor/panel/script/monaco/monaco-el-history.tsx index c9b2e67d..80236a9a 100644 --- a/app/web/src/render/editor/panel/script/monaco/monaco-el-history.tsx +++ b/app/web/src/render/editor/panel/script/monaco/monaco-el-history.tsx @@ -1,7 +1,7 @@ -import { UseStore, del, getMany, keys } from "idb-keyval"; +import { UseStore, getMany, keys } from "idb-keyval"; import { useGlobal, useLocal } from "web-utils"; -import { EditorGlobal } from "../../../logic/global"; import { Tooltip } from "../../../../../utils/ui/tooltip"; +import { EditorGlobal } from "../../../logic/global"; export const MonacoElHistory = ({ store, diff --git a/app/web/src/sworker.ts b/app/web/src/sworker.ts index 6ce91091..20caffdb 100644 --- a/app/web/src/sworker.ts +++ b/app/web/src/sworker.ts @@ -2,6 +2,7 @@ import { manifest, version } from "@parcel/service-worker"; import { RadixRouter, createRouter } from "radix3"; const g = { router: null as null | RadixRouter, + offline: false, broadcast(msg: any) { // @ts-ignore const c: Clients = self.clients; @@ -22,17 +23,19 @@ addEventListener("install", (e) => (e as ExtendableEvent).waitUntil(install())); async function activate() { let shouldRefresh = false; - const keys = await caches.keys(); - await Promise.all( - keys.map(async (key) => { - if (key !== version) { - await caches.delete(key); - shouldRefresh = true; - } - }) - ); + if (!g.offline) { + const keys = await caches.keys(); + await Promise.all( + keys.map(async (key) => { + if (key !== version) { + await caches.delete(key); + shouldRefresh = true; + } + }) + ); - g.broadcast({ type: "activated", shouldRefresh }); + g.broadcast({ type: "activated", shouldRefresh, version }); + } } addEventListener("activate", (e) => (e as ExtendableEvent).waitUntil(activate()) @@ -56,19 +59,29 @@ addEventListener("fetch", async (evt) => { if (r) { return r; } - return fetch(e.request); + + try { + g.offline = false; + return await fetch(e.request); + } catch (e) { + g.offline = true; + g.broadcast({ type: "offline" }); + return new Response(); + } })() ); }); -g.broadcast({ type: "ready" }); addEventListener("message", async (e) => { const type = e.data.type; const cache = await caches.open(version); switch (type) { case "add-cache": - if (!(await cache.match(e.data.url))) { - await cache.add(e.data.url); + { + const cached = await cache.match(e.data.url); + if (!cached) { + await cache.add(e.data.url); + } } break; case "define-route": diff --git a/app/web/src/utils/script/init-api.ts b/app/web/src/utils/script/init-api.ts index 4b90c124..70d4c2e7 100644 --- a/app/web/src/utils/script/init-api.ts +++ b/app/web/src/utils/script/init-api.ts @@ -64,7 +64,6 @@ export const initApi = async (config: any, mode: "dev" | "prod" = "dev") => { if (url) { if (!w.prasiApi[url]) { try { - await reloadDBAPI(url, mode); } catch (e) {} } @@ -137,12 +136,7 @@ export const reloadDBAPI = async ( const found = await get(url, cache); if (found) { w.prasiApi[url] = JSON.parse(found); - forceReload().catch(() => { - if (url === prasiBase) { - console.error("Failed to load prasi. Reloading..."); - setTimeout(() => location.reload(), 3000); - } - }); + forceReload(); } else { await forceReload(); } diff --git a/app/web/src/utils/sync/client.ts b/app/web/src/utils/sync/client.ts index 784cc459..5db15d66 100644 --- a/app/web/src/utils/sync/client.ts +++ b/app/web/src/utils/sync/client.ts @@ -1,91 +1,106 @@ +import { DeepProxy } from "@qiwi/deep-proxy"; +import { xxhash32 } from "hash-wasm"; +import { UseStore, get } from "idb-keyval"; import { Packr } from "msgpackr"; -import { - ClientAction, - MSG_TO_CLIENT, - MSG_TO_SERVER, - ServerAction, -} from "../../../../srv/ws/sync/type"; -import { SyncSite } from "./site"; -import { createId } from "@paralleldrive/cuid2"; +import { stringify } from "safe-stable-stringify"; +import { SyncActions } from "../../../../srv/ws/sync/actions"; +import { SyncActionDefinition } from "../../../../srv/ws/sync/actions-def"; +import { initIDB } from "./idb"; const packr = new Packr({ structuredClone: true }); +const conf = { + ws: null as null | WebSocket, + client_id: "", + idb: null as null | UseStore, +}; -export class SyncClient { - private id = ""; - private ws: WebSocket; - private wsPending?: Promise; - public connected = false; - public loaded = { - site: new Map(), +type User = { + id: string; + name: string; +}; + +export const clientStartSync = async (arg: { + user_id: string; + events: { + site_open: (arg: { site_id: string; user: User }) => void; }; - - public site = { - load: async (id: string) => { - this.loaded.site.set(id, new SyncSite(this, id)); - }, - }; - - public _internal = { - msg: { - pending: {} as Record>, - resolve: {} as Record void>, - }, - send: async (msg: MSG_TO_SERVER) => { - const { resolve, pending } = this._internal.msg; - const msg_client_id = createId(); - pending[msg_client_id] = new Promise((done) => { - resolve[msg_client_id] = done; - }); - - if (this.wsPending) { - await this.wsPending; +}) => { + const { user_id, events } = arg; + conf.idb = initIDB(user_id); + await connect(user_id); + const path: any[] = []; + return new DeepProxy( + SyncActionDefinition, + ({ trapName, value, key, DEFAULT, PROXY }) => { + if (trapName === "set") { + throw new TypeError("target is immutable"); } - this.ws.send(packr.pack({ ...msg, msg_client_id: createId() })); - }, - }; + path.push(key); + if (typeof value === "string") { + if (path[0] === "then") path.shift(); + return (...args: any[]) => + new Promise((resolve) => { + operation({ + path: path.join("."), + resolve, + args, + }); + }); + } - constructor(ws: WebSocket) { - this.ws = ws; - } + if (trapName === "get") { + if (typeof value === "object" && value !== null) { + return PROXY; + } + } - private static instance = null as SyncClient | null; - static connect() { - if (SyncClient.instance) return SyncClient.instance; + return DEFAULT; + } + ) as unknown as typeof SyncActions; +}; - const url = new URL(location.href); - url.pathname = "/sync"; - url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; +const connect = (user_id: string) => { + return new Promise((resolve) => { + if (!conf.ws) { + const url = new URL(location.href); + url.pathname = "/sync"; + url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; - const ws = new WebSocket(url.toString()); - const client = new SyncClient(ws); - SyncClient.instance = client; - let promise = { - resolve: null as null | (() => void), - }; - client.wsPending = new Promise((resolve) => { - promise.resolve = resolve; - }); - ws.onopen = () => { - promise.resolve?.(); - }; - - ws.onmessage = async (e) => { - const raw = e.data as Blob; - const msg = packr.unpack( - Buffer.from(await raw.arrayBuffer()) - ) as MSG_TO_CLIENT & { - msg_server_id: string; + const ws = new WebSocket(url.toString()); + conf.ws = ws; + ws.onopen = () => { + ws.send(packr.pack({ type: "user_id", user_id })); }; - if (!client.id) { - if (msg.action === ClientAction.Identify) { - client.id = msg.id; - client.connected = true; + ws.onmessage = async (e) => { + const raw = e.data as Blob; + const msg = packr.unpack(Buffer.from(await raw.arrayBuffer())); + if (msg.type === "client_id") { + conf.client_id = msg.client_id; + resolve(ws); } - } else { - } - }; + }; + } + }); +}; - return client; +const operation = async (arg: { + path: string; + resolve: (value: any) => void; + args: any[]; +}) => { + const ws = conf.ws; + const idb = conf.idb; + if (idb) { + const sargs = stringify(arg.args); + const hargs = await xxhash32(`${arg.path}-${sargs}`); + + if (ws && ws.readyState === ws.OPEN) { + // online + } else { + // offline + const cache = await get(hargs, idb); + console.log(cache); + } } -} +}; diff --git a/app/web/src/utils/sync/idb.ts b/app/web/src/utils/sync/idb.ts new file mode 100644 index 00000000..1e0758c9 --- /dev/null +++ b/app/web/src/utils/sync/idb.ts @@ -0,0 +1,5 @@ +import { createStore } from "idb-keyval"; +export const initIDB = (user_id: string) => { + const store = createStore(`prasi-user-${user_id}`, "default"); + return store; +}; diff --git a/app/web/src/utils/types/general.ts b/app/web/src/utils/types/general.ts index efdd1eb8..73c2ee48 100644 --- a/app/web/src/utils/types/general.ts +++ b/app/web/src/utils/types/general.ts @@ -33,6 +33,7 @@ export const w = window as unknown as { serverurl: string; api: any; db: any; + offline: boolean; }; export type Page = { diff --git a/bun.lockb b/bun.lockb index b599983e..5958f7e1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/pkgs/core/build.ts b/pkgs/core/build.ts index ed96fd76..d1708ebf 100644 --- a/pkgs/core/build.ts +++ b/pkgs/core/build.ts @@ -2,7 +2,7 @@ import { spawn } from "bun"; import { dir } from "dir"; import { Plugin, context } from "esbuild"; import { $ } from "execa"; -import { removeAsync, writeAsync } from "fs-jetpack"; +import { listAsync, removeAsync, writeAsync } from "fs-jetpack"; await removeAsync(dir.path("app/web/.parcel-cache")); await removeAsync(dir.path("app/static")); diff --git a/pkgs/core/index.ts b/pkgs/core/index.ts index 0509bb60..5af17d15 100644 --- a/pkgs/core/index.ts +++ b/pkgs/core/index.ts @@ -8,6 +8,7 @@ import { ensureNotRunning } from "./utils/ensure"; import { g } from "./utils/global"; import { createLogger } from "./utils/logger"; import { preparePrisma } from "./utils/prisma"; +import { syncActionDefinition } from "utils/sync-def"; g.status = "init"; @@ -17,7 +18,6 @@ g.mode = process.argv.includes("dev") ? "dev" : "prod"; g.datadir = g.mode == "prod" ? "../data" : "data"; g.port = parseInt(process.env.PORT || "4550"); - g.log.info(g.mode === "dev" ? "DEVELOPMENT" : "PRODUCTION"); if (g.mode === "dev") { await startDevWatcher(); @@ -31,19 +31,11 @@ if (g.db) { g.log.error(`[DB ERROR]\n${e.message}`); }); } - - + +await syncActionDefinition(); await parcelBuild(); - await generateAPIFrm(); await prepareApiRoutes(); - -// Bun.serve({ -// port: g.port, -// async fetch(req, server) { -// return new Response("test. sabar. ya...."); -// }, -// }); await createServer(); await prepareAPITypes(); diff --git a/pkgs/core/utils/sync-def.ts b/pkgs/core/utils/sync-def.ts new file mode 100644 index 00000000..68fcdc20 --- /dev/null +++ b/pkgs/core/utils/sync-def.ts @@ -0,0 +1,31 @@ +import { dir } from "dir"; +import { SyncActions } from "../../../app/srv/ws/sync/actions"; +import { writeAsync } from "fs-jetpack"; + +export const syncActionDefinition = async () => { + const def: any = {}; + let idx = 0; + + const paths = {} as Record; + const walk = (act: any, d: any, parentPaths: string[]) => { + for (const [k, v] of Object.entries(act)) { + d[k] = typeof v === "function" ? idx++ + "" : {}; + + if (typeof d[k] === "string") { + paths[d[k]] = [...parentPaths, k].join("."); + } + + if (typeof d[k] === "object") { + walk(v, d[k], [...parentPaths, k]); + } + } + }; + walk(SyncActions, def, []); + + await writeAsync( + dir.path("app/srv/ws/sync/actions-def.ts"), + `\ +export const SyncActionDefinition = ${JSON.stringify(def, null, 2)}; +export const SyncActionPaths = ${JSON.stringify(paths, null, 2)}; ` + ); +};