prasi-bun/app/web/src/utils/sync/client.ts

193 lines
5.0 KiB
TypeScript

import { DeepProxy } from "@qiwi/deep-proxy";
import { xxhash32 } from "hash-wasm";
import { UseStore, get, set } from "idb-keyval";
import { Packr } from "msgpackr";
import { stringify } from "safe-stable-stringify";
import { SyncActions } from "../../../../srv/ws/sync/actions";
import {
SyncActionDefinition,
SyncActionPaths,
} from "../../../../srv/ws/sync/actions-def";
import { initIDB } from "./idb";
import { SyncType } from "../../../../srv/ws/sync/type";
import { w } from "../types/general";
import { ESite } from "../../render/ed/logic/ed-global";
const packr = new Packr({ structuredClone: true });
const conf = {
ws: null as null | WebSocket,
client_id: "",
idb: null as null | UseStore,
event: null as null | ClientEventObject,
};
const runtime = {
action: {
pending: {} as Record<
string,
{ ts: number; resolve: (value: any) => void }
>,
},
};
type User = {
id: string;
name: string;
};
export type ClientEventObject = Parameters<typeof clientStartSync>[0]["events"];
export type ClientEvent = keyof ClientEventObject;
export const clientStartSync = async (arg: {
user_id: string;
events: {
editor_start: (arg: {
user_id: string;
site_id?: string;
page_id?: string;
}) => void;
site_loaded: (arg: { site: ESite }) => void;
};
}) => {
const { user_id, events } = arg;
conf.idb = initIDB(user_id);
await connect(user_id, events);
return new DeepProxy(
SyncActionDefinition,
({ target, trapName, value, key, DEFAULT, PROXY }) => {
if (trapName === "set") {
throw new TypeError("target is immutable");
}
if (typeof value === "string") {
return (...args: any[]) => {
return new Promise((resolve) => {
doAction({
code: value,
resolve,
args,
});
});
};
}
if (trapName === "get") {
if (typeof value === "object" && value !== null) {
return PROXY;
}
}
return DEFAULT;
}
) as unknown as typeof SyncActions;
};
const connect = (user_id: string, event: ClientEventObject) => {
conf.event = event;
if (w.offline) {
return new Promise<void>(async (resolve) => {
resolve();
const eventName = "editor_start";
const data = await loadEventOffline(eventName);
if (event[eventName]) {
event[eventName](data);
}
});
} else {
return new Promise<void>((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());
ws.onopen = () => {
ws.send(packr.pack({ type: SyncType.UserID, user_id }));
conf.ws = ws;
};
ws.onclose = async () => {
w.offline = true;
if (!conf.ws) {
await connect(user_id, event);
resolve();
}
};
ws.onmessage = async (e) => {
const raw = e.data as Blob;
const msg = packr.unpack(Buffer.from(await raw.arrayBuffer()));
if (msg.type === SyncType.ClientID) {
conf.client_id = msg.client_id;
resolve();
} else if (msg.type === SyncType.Event) {
const eventName = msg.event as ClientEvent;
if (event[eventName]) {
if (offlineEvents.includes(eventName)) {
saveEventOffline(eventName, msg.data);
}
event[eventName](msg.data);
}
} else if (msg.type === SyncType.ActionResult) {
const pending = runtime.action.pending[msg.argid];
if (pending) {
delete runtime.action.pending[msg.argid];
const idb = conf.idb;
if (idb) {
await set(msg.argid, msg.val, idb);
}
pending.resolve(msg.val);
}
}
};
}
});
}
};
const offlineEvents: ClientEvent[] = ["editor_start"];
const saveEventOffline = async (name: ClientEvent, data: any) => {
const idb = conf.idb;
if (idb) {
const hargs = await xxhash32(`ev-${name}`);
await set(hargs, data, idb);
}
};
const loadEventOffline = async (name: ClientEvent) => {
const idb = conf.idb;
if (idb) {
const hargs = await xxhash32(`ev-${name}`);
return await get(hargs, idb);
}
};
const doAction = async <T>(arg: {
code: string;
resolve: (value: any) => void;
args: any[];
}) => {
const { args, code, resolve } = arg;
const ws = conf.ws;
const idb = conf.idb;
if (idb) {
const sargs = stringify(args);
const path = (SyncActionPaths as any)[code];
const argid = await xxhash32(`op-${path}-${sargs}`);
if (ws && ws.readyState === ws.OPEN) {
// online
runtime.action.pending[argid] = {
ts: Date.now(),
resolve,
};
ws.send(packr.pack({ type: SyncType.Action, code, args, argid }));
} else {
// offline
const cache = await get(argid, idb);
resolve(cache as T);
}
}
};