This commit is contained in:
Rizky 2023-10-20 11:20:01 +07:00
parent 0170eeeadc
commit 2fddcf1a78
19 changed files with 392 additions and 175 deletions

View File

@ -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"
};

View File

@ -0,0 +1,22 @@
import { component, site, page } from "dbgen";
export const SyncActions = {
site: {
all: () =>
({}) as Promise<
Record<string, { id: string; name: string; domain: string }>
>,
group: () => ({}) as Promise<Record<string, string[]>>,
load: (id: string) => ({}) as Promise<site>,
},
comp: {
all: () => ({}) as Record<string, Exclude<component, "content_tree">>,
group: () => ({}) as Record<string, string[]>,
doc: (id: string) => ({}) as Uint8Array,
},
page: {
all: (id_site: string) =>
({}) as Record<string, Exclude<page, "content_tree">>,
load: (id: string) => ({}) as Uint8Array,
},
};

View File

@ -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 { 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 packr = new Packr({ structuredClone: true });
const conns = new Map< const conns = new Map<
string, string,
{ {
user_id: string;
ws: ServerWebSocket<WSData>; ws: ServerWebSocket<WSData>;
msg: { msg: {
pending: Record<string, Promise<any>>; pending: Record<string, Promise<any>>;
@ -18,21 +18,31 @@ const conns = new Map<
const wconns = new WeakMap<ServerWebSocket<WSData>, string>(); const wconns = new WeakMap<ServerWebSocket<WSData>, string>();
export const syncHandler: WebSocketHandler<WSData> = { export const syncHandler: WebSocketHandler<WSData> = {
open(ws) { open(ws) {
const id = createId(); const client_id = createId();
conns.set(id, { ws, msg: { pending: {}, resolve: {} } }); conns.set(client_id, {
wconns.set(ws, id); user_id: "",
ws.sendBinary(packr.pack({ type: "identify", 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) { message(ws, raw) {
const conn_id = wconns.get(ws); const conn_id = wconns.get(ws);
if (conn_id) { if (conn_id) {
const conn = conns.get(conn_id); const conn = conns.get(conn_id);
if (conn) { if (conn) {
const msg = packr.unpack(Buffer.from(raw)) as MSG_TO_SERVER & { const msg = packr.unpack(Buffer.from(raw));
msg_client_id: string; if (msg.type === "user_id") {
}; const { user_id } = msg;
conn.user_id = user_id;
switch (msg.action) {
} }
} }
} }

View File

@ -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;
};

View File

@ -13,13 +13,14 @@
"@paralleldrive/cuid2": "2.2.2", "@paralleldrive/cuid2": "2.2.2",
"@parcel/packager-wasm": "^2.10.0", "@parcel/packager-wasm": "^2.10.0",
"@parcel/service-worker": "^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", "@swc/wasm-web": "1.3.94-nightly-20231014.1",
"algoliasearch": "^4.20.0", "algoliasearch": "^4.20.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"dbgen": "workspace:*", "dbgen": "workspace:*",
"downshift": "^8.2.2", "downshift": "^8.2.2",
"esbuild-wasm": "^0.19.4", "esbuild-wasm": "^0.19.4",
"hash-wasm": "^4.10.0",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"js-base64": "^3.7.5", "js-base64": "^3.7.5",
"lodash.capitalize": "^4.2.1", "lodash.capitalize": "^4.2.1",
@ -38,6 +39,7 @@
"lodash.uniq": "^4.5.0", "lodash.uniq": "^4.5.0",
"lodash.uniqby": "^4.7.0", "lodash.uniqby": "^4.7.0",
"monaco-jsx-syntax-highlight-v2": "^1.2.2", "monaco-jsx-syntax-highlight-v2": "^1.2.2",
"msgpackr": "^1.9.9",
"polywasm": "^0.1.4", "polywasm": "^0.1.4",
"prettier": "3.0.3", "prettier": "3.0.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
@ -50,6 +52,7 @@
"react-is": "^18.2.0", "react-is": "^18.2.0",
"react-use-error-boundary": "^3.0.0", "react-use-error-boundary": "^3.0.0",
"react-virtuoso": "^4.6.1", "react-virtuoso": "^4.6.1",
"safe-stable-stringify": "^2.4.3",
"svgo": "^3.0.2", "svgo": "^3.0.2",
"textdiff-create": "^1.1.9", "textdiff-create": "^1.1.9",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",

View File

@ -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 <Loading />;
}
if (!p.sync) {
// p.sync = clientStartSync({
// user_id: session.data.user.id,
// });
return <Loading />;
}
return <div></div>;
},
});

View File

@ -26,3 +26,7 @@ export const live = {
url: "/live/:domain/**", url: "/live/:domain/**",
page: () => import("./page/live"), page: () => import("./page/live"),
}; };
export const ned = {
url: "/ned/:site_id/:page_id",
page: () => import("./page/ned"),
};

View File

@ -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 { defineReact, defineWindow } from "web-utils";
import { Root } from "./base/root"; import { Root } from "./base/root";
import "./index.css"; import "./index.css";
@ -6,35 +6,107 @@ import { createAPI, createDB, reloadDBAPI } from "./utils/script/init-api";
import { w } from "./utils/types/general"; import { w } from "./utils/types/general";
const start = async () => { 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)) { if (!["localhost", "127.0.0.1"].includes(location.hostname)) {
const sw = await registerServiceWorker(); const sw = await registerServiceWorker();
navigator.serviceWorker.addEventListener("message", (e) => { navigator.serviceWorker.addEventListener("message", (e) => {
if (e.data.type === "activated") { if (react.root) {
if (e.data.shouldRefresh && sw) { if (e.data.type === "offline") {
sw.unregister().then(() => { w.offline = true;
window.location.reload(); const click = () => {
}); if (react.root) react.root.render(<Root />);
} };
} setTimeout(click, 5000);
if (e.data.type === "ready") { react.root.render(
const sw = navigator.serviceWorker.controller; <>
<Root />
if (sw) { <div
const routes = Object.entries(w.prasiApi[base].apiEntry).map( className={cx(
([k, v]: any) => ({ css`
url: v.url, position: fixed;
name: k, bottom: 20px;
}) left: 0px;
right: 0px;
z-index: 999;
`,
"flex justify-center cursor-pointer"
)}
>
<div
className="bg-orange-500 text-white px-4 py-2 rounded-full text-sm"
onClick={click}
>
Network Failed: Offline Mode
</div>
</div>
</>
); );
}
sw.postMessage({ if (e.data.type === "activated") {
type: "add-cache", if (e.data.shouldRefresh && sw) {
url: location.href, react.root.render(
}); <>
sw.postMessage({ <Root />
type: "define-route", <div
routes, className={cx(
}); css`
position: fixed;
bottom: 20px;
left: 0px;
right: 0px;
z-index: 999;
`,
"flex justify-center"
)}
>
<div className="bg-blue-400 text-white px-4 py-2 rounded-full text-sm">
Updating App...
</div>
</div>
</>
);
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(<Root />);
};
setTimeout(click, 5000);
react.root.render(
<>
<Root />
<div
className={cx(
css`
position: fixed;
bottom: 20px;
left: 0px;
right: 0px;
z-index: 999;
`,
"flex justify-center cursor-pointer"
)}
>
<div
className="bg-green-600 text-white px-4 py-2 rounded-full text-sm"
onClick={click}
>
App Updated, Ready to use offline
</div>
</div>
</>
);
}
}
} }
} }
}); });
@ -48,15 +120,36 @@ const start = async () => {
defineReact(); defineReact();
await defineWindow(false); await defineWindow(false);
const base = `${location.protocol}//${location.host}`;
w.serverurl = base; w.serverurl = base;
await reloadDBAPI(base, "prod"); 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.api = createAPI(base);
w.db = createDB(base); w.db = createDB(base);
const el = document.getElementById("root"); const el = document.getElementById("root");
if (el) { if (el) {
createRoot(el).render(<Root />); react.root = createRoot(el);
react.root.render(<Root />);
} }
}; };

View File

@ -9,6 +9,7 @@ import { IRoot } from "../../../utils/types/root";
import { LSite } from "../../live/logic/global"; import { LSite } from "../../live/logic/global";
import { ISection } from "../../../utils/types/section"; import { ISection } from "../../../utils/types/section";
import { IText } from "../../../utils/types/text"; import { IText } from "../../../utils/types/text";
import { clientStartSync } from "../../../utils/sync/client";
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] }; export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
export type NodeMeta = { meta: ItemMeta; idx: number }; export type NodeMeta = { meta: ItemMeta; idx: number };
@ -40,6 +41,7 @@ export type ItemMeta = {
export const EditorGlobal = { export const EditorGlobal = {
/** ui */ /** ui */
mode: "" as "desktop" | "mobile", mode: "" as "desktop" | "mobile",
sync: null as unknown as ReturnType<typeof clientStartSync>,
status: "init" as status: "init" as
| "init" | "init"
| "loading" | "loading"

View File

@ -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 { useGlobal, useLocal } from "web-utils";
import { EditorGlobal } from "../../../logic/global";
import { Tooltip } from "../../../../../utils/ui/tooltip"; import { Tooltip } from "../../../../../utils/ui/tooltip";
import { EditorGlobal } from "../../../logic/global";
export const MonacoElHistory = ({ export const MonacoElHistory = ({
store, store,

View File

@ -2,6 +2,7 @@ import { manifest, version } from "@parcel/service-worker";
import { RadixRouter, createRouter } from "radix3"; import { RadixRouter, createRouter } from "radix3";
const g = { const g = {
router: null as null | RadixRouter<any>, router: null as null | RadixRouter<any>,
offline: false,
broadcast(msg: any) { broadcast(msg: any) {
// @ts-ignore // @ts-ignore
const c: Clients = self.clients; const c: Clients = self.clients;
@ -22,17 +23,19 @@ addEventListener("install", (e) => (e as ExtendableEvent).waitUntil(install()));
async function activate() { async function activate() {
let shouldRefresh = false; let shouldRefresh = false;
const keys = await caches.keys(); if (!g.offline) {
await Promise.all( const keys = await caches.keys();
keys.map(async (key) => { await Promise.all(
if (key !== version) { keys.map(async (key) => {
await caches.delete(key); if (key !== version) {
shouldRefresh = true; await caches.delete(key);
} shouldRefresh = true;
}) }
); })
);
g.broadcast({ type: "activated", shouldRefresh }); g.broadcast({ type: "activated", shouldRefresh, version });
}
} }
addEventListener("activate", (e) => addEventListener("activate", (e) =>
(e as ExtendableEvent).waitUntil(activate()) (e as ExtendableEvent).waitUntil(activate())
@ -56,19 +59,29 @@ addEventListener("fetch", async (evt) => {
if (r) { if (r) {
return 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) => { addEventListener("message", async (e) => {
const type = e.data.type; const type = e.data.type;
const cache = await caches.open(version); const cache = await caches.open(version);
switch (type) { switch (type) {
case "add-cache": 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; break;
case "define-route": case "define-route":

View File

@ -64,7 +64,6 @@ export const initApi = async (config: any, mode: "dev" | "prod" = "dev") => {
if (url) { if (url) {
if (!w.prasiApi[url]) { if (!w.prasiApi[url]) {
try { try {
await reloadDBAPI(url, mode); await reloadDBAPI(url, mode);
} catch (e) {} } catch (e) {}
} }
@ -137,12 +136,7 @@ export const reloadDBAPI = async (
const found = await get(url, cache); const found = await get(url, cache);
if (found) { if (found) {
w.prasiApi[url] = JSON.parse(found); w.prasiApi[url] = JSON.parse(found);
forceReload().catch(() => { forceReload();
if (url === prasiBase) {
console.error("Failed to load prasi. Reloading...");
setTimeout(() => location.reload(), 3000);
}
});
} else { } else {
await forceReload(); await forceReload();
} }

View File

@ -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 { Packr } from "msgpackr";
import { import { stringify } from "safe-stable-stringify";
ClientAction, import { SyncActions } from "../../../../srv/ws/sync/actions";
MSG_TO_CLIENT, import { SyncActionDefinition } from "../../../../srv/ws/sync/actions-def";
MSG_TO_SERVER, import { initIDB } from "./idb";
ServerAction,
} from "../../../../srv/ws/sync/type";
import { SyncSite } from "./site";
import { createId } from "@paralleldrive/cuid2";
const packr = new Packr({ structuredClone: true }); const packr = new Packr({ structuredClone: true });
const conf = {
ws: null as null | WebSocket,
client_id: "",
idb: null as null | UseStore,
};
export class SyncClient { type User = {
private id = ""; id: string;
private ws: WebSocket; name: string;
private wsPending?: Promise<void>; };
public connected = false;
public loaded = { export const clientStartSync = async (arg: {
site: new Map<string, SyncSite>(), user_id: string;
events: {
site_open: (arg: { site_id: string; user: User }) => void;
}; };
}) => {
public site = { const { user_id, events } = arg;
load: async (id: string) => { conf.idb = initIDB(user_id);
this.loaded.site.set(id, new SyncSite(this, id)); await connect(user_id);
}, const path: any[] = [];
}; return new DeepProxy(
SyncActionDefinition,
public _internal = { ({ trapName, value, key, DEFAULT, PROXY }) => {
msg: { if (trapName === "set") {
pending: {} as Record<string, Promise<any>>, throw new TypeError("target is immutable");
resolve: {} as Record<string, (result: any) => 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;
} }
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) { if (trapName === "get") {
this.ws = ws; if (typeof value === "object" && value !== null) {
} return PROXY;
}
}
private static instance = null as SyncClient | null; return DEFAULT;
static connect() { }
if (SyncClient.instance) return SyncClient.instance; ) as unknown as typeof SyncActions;
};
const url = new URL(location.href); const connect = (user_id: string) => {
url.pathname = "/sync"; return new Promise<WebSocket>((resolve) => {
url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; 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 ws = new WebSocket(url.toString());
const client = new SyncClient(ws); conf.ws = ws;
SyncClient.instance = client; ws.onopen = () => {
let promise = { ws.send(packr.pack({ type: "user_id", user_id }));
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;
}; };
if (!client.id) { ws.onmessage = async (e) => {
if (msg.action === ClientAction.Identify) { const raw = e.data as Blob;
client.id = msg.id; const msg = packr.unpack(Buffer.from(await raw.arrayBuffer()));
client.connected = true; 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);
}
} }
} };

View File

@ -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;
};

View File

@ -33,6 +33,7 @@ export const w = window as unknown as {
serverurl: string; serverurl: string;
api: any; api: any;
db: any; db: any;
offline: boolean;
}; };
export type Page = { export type Page = {

BIN
bun.lockb

Binary file not shown.

View File

@ -2,7 +2,7 @@ import { spawn } from "bun";
import { dir } from "dir"; import { dir } from "dir";
import { Plugin, context } from "esbuild"; import { Plugin, context } from "esbuild";
import { $ } from "execa"; 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/web/.parcel-cache"));
await removeAsync(dir.path("app/static")); await removeAsync(dir.path("app/static"));

View File

@ -8,6 +8,7 @@ import { ensureNotRunning } from "./utils/ensure";
import { g } from "./utils/global"; import { g } from "./utils/global";
import { createLogger } from "./utils/logger"; import { createLogger } from "./utils/logger";
import { preparePrisma } from "./utils/prisma"; import { preparePrisma } from "./utils/prisma";
import { syncActionDefinition } from "utils/sync-def";
g.status = "init"; g.status = "init";
@ -17,7 +18,6 @@ g.mode = process.argv.includes("dev") ? "dev" : "prod";
g.datadir = g.mode == "prod" ? "../data" : "data"; g.datadir = g.mode == "prod" ? "../data" : "data";
g.port = parseInt(process.env.PORT || "4550"); g.port = parseInt(process.env.PORT || "4550");
g.log.info(g.mode === "dev" ? "DEVELOPMENT" : "PRODUCTION"); g.log.info(g.mode === "dev" ? "DEVELOPMENT" : "PRODUCTION");
if (g.mode === "dev") { if (g.mode === "dev") {
await startDevWatcher(); await startDevWatcher();
@ -31,19 +31,11 @@ if (g.db) {
g.log.error(`[DB ERROR]\n${e.message}`); g.log.error(`[DB ERROR]\n${e.message}`);
}); });
} }
await syncActionDefinition();
await parcelBuild(); await parcelBuild();
await generateAPIFrm(); await generateAPIFrm();
await prepareApiRoutes(); await prepareApiRoutes();
// Bun.serve({
// port: g.port,
// async fetch(req, server) {
// return new Response("test. sabar. ya....");
// },
// });
await createServer(); await createServer();
await prepareAPITypes(); await prepareAPITypes();

View File

@ -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<string, string>;
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)}; `
);
};