diff --git a/app/srv/exports.d.ts b/app/srv/exports.d.ts index e69de29b..2ebf905c 100644 --- a/app/srv/exports.d.ts +++ b/app/srv/exports.d.ts @@ -0,0 +1,44 @@ +declare module "exports" { + export const _web: { + name: string; + url: string; + path: string; + args: string[]; + handler: Promise; + }; + export const _upload: { + name: string; + url: string; + path: string; + args: string[]; + handler: Promise; + }; + export const _prasi: { + name: string; + url: string; + path: string; + args: any[]; + handler: Promise; + }; + export const _file: { + name: string; + url: string; + path: string; + args: any[]; + handler: Promise; + }; + export const _api_frm: { + name: string; + url: string; + path: string; + args: any[]; + handler: Promise; + }; + export const _dbs: { + name: string; + url: string; + path: string; + args: string[]; + handler: Promise; + }; +} diff --git a/app/srv/exports.ts b/app/srv/exports.ts index e69de29b..899dd2c6 100644 --- a/app/srv/exports.ts +++ b/app/srv/exports.ts @@ -0,0 +1,42 @@ +export const _web = { + name: "_web", + url: "/_web/:id/**", + path: "app/srv/api/_web.ts", + args: ["id","_"], + handler: import("./api/_web") +} +export const _upload = { + name: "_upload", + url: "/_upload", + path: "app/srv/api/_upload.ts", + args: ["body"], + handler: import("./api/_upload") +} +export const _prasi = { + name: "_prasi", + url: "/_prasi/**", + path: "app/srv/api/_prasi.ts", + args: [], + handler: import("./api/_prasi") +} +export const _file = { + name: "_file", + url: "/_file/**", + path: "app/srv/api/_file.ts", + args: [], + handler: import("./api/_file") +} +export const _api_frm = { + name: "_api_frm", + url: "/_api_frm", + path: "app/srv/api/_api_frm.ts", + args: [], + handler: import("./api/_api_frm") +} +export const _dbs = { + name: "_dbs", + url: "/_dbs/:dbName/:action", + path: "app/srv/api/_dbs.ts", + args: ["dbName","action"], + handler: import("./api/_dbs") +} \ No newline at end of file diff --git a/app/web/package.json b/app/web/package.json index a788c239..71a1c3cc 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -15,6 +15,7 @@ "@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", "idb-keyval": "^6.2.1", @@ -40,6 +41,7 @@ "prettier": "3.0.3", "prop-types": "^15.8.1", "quill-delta": "^5.1.0", + "radix3": "^1.1.0", "react": "18.2.0", "react-colorful": "^5.6.1", "react-dnd": "^16.0.1", @@ -52,11 +54,10 @@ "tinycolor2": "^1.6.0", "ua-parser-js": "^1.0.36", "uuid": "9.0.1", + "web-utils": "workspace:*", "y-pojo": "^0.0.8", "yjs": "^13.6.8", - "yjs-types": "^0.0.1", - "web-utils": "workspace:*", - "dbgen": "workspace:*" + "yjs-types": "^0.0.1" }, "devDependencies": { "@types/lodash.concat": "^4.5.7", diff --git a/app/web/src/base/page/all.tsx b/app/web/src/base/page/all.tsx index 4655d4f3..d25b2346 100644 --- a/app/web/src/base/page/all.tsx +++ b/app/web/src/base/page/all.tsx @@ -1,9 +1,12 @@ +import { useEffect } from "react"; import { page } from "web-utils"; export default page({ - url: "*", + url: "**", component: ({}) => { - navigate("/login"); + useEffect(() => { + navigate("/login"); + }, []); return
Loading...
; }, }); diff --git a/app/web/src/base/page/live.tsx b/app/web/src/base/page/live.tsx index f98d47d9..2ffe6213 100644 --- a/app/web/src/base/page/live.tsx +++ b/app/web/src/base/page/live.tsx @@ -13,6 +13,7 @@ export default page({ return ( import("./page/all"), +}; +export const login = { + url: "/login", + page: () => import("./page/auth/login"), +}; diff --git a/app/web/src/base/root.tsx b/app/web/src/base/root.tsx index 8aa6671d..afbac143 100644 --- a/app/web/src/base/root.tsx +++ b/app/web/src/base/root.tsx @@ -1,25 +1,45 @@ -import { FC } from "react"; - -import { useState } from "react"; -import { GlobalContext } from "web-utils"; - -const w = window as unknown as { - prasiContext: any; - rootRender: any; -}; - -w.prasiContext = { - global: {}, - render() {}, -}; +import { createRouter } from "radix3"; +import { FC, Suspense, lazy } from "react"; +import { GlobalContext, useLocal } from "web-utils"; +import { Loading } from "../utils/ui/loading"; export const Root: FC<{}> = ({}) => { - const [_, render] = useState({}); - w.prasiContext.render = () => { - render({}); - }; - w.rootRender = w.prasiContext.render; + const local = useLocal( + { + router: createRouter({ strictTrailingSlash: true }), + Page: null as any, + }, + async () => { + const pages = await import("./pages"); + for (const [_, v] of Object.entries(pages)) { + local.router.insert( + v.url, + lazy(async () => { + return { default: (await v.page()).default.component as any }; + }) + ); + } + local.render(); + } + ); + prasiContext.render = local.render; const Provider = GlobalContext.Provider as FC<{ value: any; children: any }>; - return Hello mantapun alamuko; + + const found = local.router.lookup(location.pathname); + if (found) { + local.Page = found; + } + + if (!local.Page) { + return ; + } + + return ( + + + + + + ); }; diff --git a/app/web/src/index.tsx b/app/web/src/index.tsx index 5561ed89..d06e95fc 100644 --- a/app/web/src/index.tsx +++ b/app/web/src/index.tsx @@ -1,6 +1,8 @@ import { createRoot } from "react-dom/client"; -import "./index.css"; +import { defineReact, defineWindow } from "web-utils"; import { Root } from "./base/root"; +import "./index.css"; +import { createAPI, createDB, reloadDBAPI } from "./utils/script/init-api"; const registerServiceWorker = async () => { if ("serviceWorker" in navigator) { @@ -21,6 +23,18 @@ const registerServiceWorker = async () => { registerServiceWorker(); const el = document.getElementById("root"); + if (el) { - createRoot(el).render(); + (async () => { + defineReact(); + await defineWindow(false); + const w = window as any; + const base = `${location.protocol}//${location.host}`; + + await reloadDBAPI(base); + w.api = createAPI(base); + w.db = createDB(base); + + createRoot(el).render(); + })(); } diff --git a/app/web/src/utils/script/init-api.ts b/app/web/src/utils/script/init-api.ts index c5ef4abe..20647000 100644 --- a/app/web/src/utils/script/init-api.ts +++ b/app/web/src/utils/script/init-api.ts @@ -1,7 +1,7 @@ import { createStore, get, set } from "idb-keyval"; import trim from "lodash.trim"; import { apiClient, dbClient } from "web-utils"; -import { createFrameCors } from "web-utils/src/web/iframe-cors"; +import { createFrameCors } from "web-utils"; export const w = window as unknown as { prasiApi: Record; apiHeaders: any; @@ -16,6 +16,10 @@ export const createAPI = (url: string) => { w.apiClient = apiClient; } + if (!w.prasiApi) { + w.prasiApi = {}; + } + return w.apiClient(w.prasiApi[url]?.apiEntry, url); }; diff --git a/bun.lockb b/bun.lockb index 4fa6d14f..3669b7c8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index faa9281c..5b5260f6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "module": "src/index.ts", "type": "module", "scripts": { - "dev": "bun run --silent --watch ./pkgs/core/index.ts dev", + "dev": "bun clean && bun run --silent --watch ./pkgs/core/index.ts dev", "clean": "rm -rf app/static && rm -rf app/web/.parcel-cache", "prod": "bun run --silent ./pkgs/core/index.ts", "pull": "cd app/db && bun prisma db pull && bun prisma generate", diff --git a/pkgs/core/server/api-scan.ts b/pkgs/core/server/api-scan.ts index 5ef93e1b..74772ad5 100644 --- a/pkgs/core/server/api-scan.ts +++ b/pkgs/core/server/api-scan.ts @@ -48,5 +48,5 @@ export const scanApi = async () => { } }; await scan(dir(`app/srv/api`)); - await scan(dir(`pkgs/api`)); + await scan(dir(`pkgs/core/api`)); }; diff --git a/pkgs/core/server/create.ts b/pkgs/core/server/create.ts index c0fd2b03..4eb35904 100644 --- a/pkgs/core/server/create.ts +++ b/pkgs/core/server/create.ts @@ -13,25 +13,18 @@ export const createServer = async () => { async fetch(req) { const url = new URL(req.url); - if (req.method === "GET") { - try { - const file = Bun.file(dir(`app/static${url.pathname}`)); - if (file.type !== "application/octet-stream") { - return new Response(file as any); - } - } catch (e) {} - return new Response(Bun.file(dir(`app/static/index.html`)) as any); - } else { - const api = await serveAPI(url, req); - if (api) { - return api; - } + const api = await serveAPI(url, req); + if (api) { + return api; } - return new Response(`404 Not Found`, { - status: 404, - statusText: "Not Found", - }); + try { + const file = Bun.file(dir(`app/static${url.pathname}`)); + if (file.type !== "application/octet-stream") { + return new Response(file as any); + } + } catch (e) {} + return new Response(Bun.file(dir(`app/static/index.html`)) as any); }, }); diff --git a/pkgs/web-utils/package.json b/pkgs/web-utils/package.json index d960a4ce..dca1b78d 100644 --- a/pkgs/web-utils/package.json +++ b/pkgs/web-utils/package.json @@ -3,7 +3,9 @@ "main": "src/export.ts", "dependencies": { "@paralleldrive/cuid2": "2.2.0", + "@types/hash-sum": "^1.0.0", "goober": "^2.1.13", + "hash-sum": "^2.0.0", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/pkgs/web-utils/src/client-api.ts b/pkgs/web-utils/src/client-api.ts new file mode 100644 index 00000000..b3c211aa --- /dev/null +++ b/pkgs/web-utils/src/client-api.ts @@ -0,0 +1,78 @@ +import { fetchSendApi } from "./client-frame"; + +export const apiClient = ( + api: Record, + apiUrl: string +) => { + return new Proxy( + {}, + { + get: (_, actionName: string) => { + const createFn = (actionName: string) => { + return function (this: { apiUrl: string } | undefined, ...rest: any) { + return new Promise(async (resolve, reject) => { + try { + let _apiURL = apiUrl; + if (typeof this?.apiUrl === "string") { + _apiURL = this.apiUrl; + } + + if (!api || !api[actionName]) { + resolve(null); + console.error( + `API ${actionName.toString()} not found, existing API: ${Object.keys( + api + )}` + ); + return; + } + + let actionUrl = api[actionName].url; + const actionParams = api[actionName].args; + if (actionUrl && actionParams) { + if (rest.length > 0 && actionParams.length > 0) { + for (const [idx, p] of Object.entries(rest)) { + const paramName = actionParams[parseInt(idx)]; + if (actionParams && actionParams.includes(paramName)) { + if ( + !!p && + typeof p !== "string" && + typeof p !== "number" + ) { + continue; + } + } + actionUrl = actionUrl.replace(`:${paramName}?`, p + ""); + actionUrl = actionUrl.replace(`:${paramName}`, p + ""); + } + } + + const url = `${_apiURL}${actionUrl}`; + + const result = await fetchSendApi(url, rest); + resolve(result); + } else { + console.error(`API Not Found: ${actionName.toString()}`); + } + } catch (e) { + reject(e); + } + }); + }; + }; + if (actionName === "then") { + return new Proxy( + {}, + { + get: (_, actionName: string) => { + return createFn(actionName); + }, + } + ); + } + + return createFn(actionName); + }, + } + ); +}; diff --git a/pkgs/web-utils/src/client-db.ts b/pkgs/web-utils/src/client-db.ts new file mode 100644 index 00000000..7be56acb --- /dev/null +++ b/pkgs/web-utils/src/client-db.ts @@ -0,0 +1,131 @@ +import { waitUntil } from "web-utils"; +import { createFrameCors } from "./client-frame"; +import hash_sum from "hash-sum"; + +export const dbClient = (name: string, dburl?: string) => { + return new Proxy( + {}, + { + get(_, table: string) { + if (table === "_tables") { + return () => { + return fetchSendDb( + name, + { + name, + action: "definition", + table: "*", + }, + dburl + ); + }; + } + + if (table === "_definition") { + return (table: string) => { + return fetchSendDb( + name, + { + name, + action: "definition", + table, + }, + dburl + ); + }; + } + + if (table.startsWith("$")) { + return (...params: any[]) => { + return fetchSendDb( + name, + { + name, + action: "query", + table, + params, + }, + dburl + ); + }; + } + + return new Proxy( + {}, + { + get(_, action: string) { + return (...params: any[]) => { + if (table === "query") { + table = action; + action = "query"; + } + return fetchSendDb( + name, + { + name, + action, + table, + params, + }, + dburl + ); + }; + }, + } + ); + }, + } + ); +}; + +const cachedQueryResult: Record = + {}; + +export const fetchSendDb = async ( + name: string, + params: any, + dburl?: string +) => { + const w = typeof window === "object" ? window : (globalThis as any); + let url = `/_dbs/${name}`; + let frm: Awaited>; + + if (params.table) { + url += `/${params.table}`; + } + + const _base = dburl || w.serverurl; + + if (!w.frmapi) { + w.frmapi = {}; + } + + if (!w.frmapi[_base]) { + w.frmapi[_base] = await createFrameCors(_base); + } + + frm = w.frmapi[_base]; + + if (!frm) { + await waitUntil(() => { + frm = w.frmapi[_base]; + return frm; + }); + } + + const hsum = hash_sum(params); + const cached = cachedQueryResult[hsum]; + + if (!cached || (cached && Date.now() - cached.timestamp > 1000)) { + cachedQueryResult[hsum] = { + timestamp: Date.now(), + result: null, + }; + + const result = await frm.send(url, params, w.apiHeaders); + cachedQueryResult[hsum].result = result; + return result; + } + + return cached.result; +}; diff --git a/pkgs/web-utils/src/client-frame.ts b/pkgs/web-utils/src/client-frame.ts new file mode 100644 index 00000000..32f6f70f --- /dev/null +++ b/pkgs/web-utils/src/client-frame.ts @@ -0,0 +1,208 @@ +import { waitUntil } from "web-utils"; +import { createId } from "@paralleldrive/cuid2"; +const cuid = createId; + +(BigInt.prototype as any).toJSON = function (): string { + return `BigInt::` + this.toString(); +}; + +export const createFrameCors = async (url: string, win?: any) => { + let w = window; + if (!!win) { + w = win; + } + const document = w.document; + + const id = `__` + url.replace(/\W/g, ""); + + if (typeof document !== "undefined" && !document.querySelector(`#${id}`)) { + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + iframe.id = id; + + const _url = new URL(url); + _url.pathname = "/_api_frm"; + iframe.src = _url.toString(); + + await new Promise((resolve, reject) => { + iframe.onload = () => { + if (!iframe.contentDocument) { + setTimeout(() => { + if (!iframe.contentDocument) { + reject( + `Cannot load iframe ${_url.toString()}. content document not found.` + ); + } + }, 100); + } + }; + + const onInit = (e: any) => { + if (e.data === "initialized") { + iframe.setAttribute("loaded", "y"); + w.removeEventListener("message", onInit); + resolve(); + } + }; + w.addEventListener("message", onInit); + + document.body.appendChild(iframe); + }); + } + + const wm = {} as Record; + + const sendRaw = async ( + input: RequestInfo | URL, + init?: RequestInit | undefined + ) => { + if (w.document && w.document.querySelector) { + const iframe = w.document.querySelector(`#${id}`) as HTMLIFrameElement; + + if ( + !iframe || + !iframe.contentWindow || + (iframe && iframe.getAttribute("loaded") !== "y") + ) { + await waitUntil( + () => + iframe && + iframe.contentWindow && + iframe.getAttribute("loaded") === "y" + ); + } + + return await new Promise((resolve, reject) => { + if (iframe && iframe.contentWindow) { + const id = cuid(); + wm[id] = (e: any) => { + if (id === e.data.id) { + w.removeEventListener("message", wm[id]); + delete wm[id]; + if (e.data.error) { + let err = e.data.error; + if (typeof err === "string") { + reject( + err.replace( + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + "" + ) + ); + } + } else { + resolve(e.data.result); + } + } + }; + w.addEventListener("message", wm[id]); + + let _input = input; + if (typeof input === "string") { + if (!input.startsWith("http")) { + _input = `${url}${input}`; + } + } + iframe.contentWindow.postMessage({ input: _input, init, id }, "*"); + } + }); + } + }; + + return { + sendRaw, + async send(input: string | RequestInfo | URL, data?: any, _headers?: any) { + const uri = input.toString(); + const headers = { ..._headers }; + + let body = data; + let isFile = false; + + const formatSingle = async (data: any) => { + if (!(data instanceof w.FormData || data instanceof w.File)) { + headers["content-type"] = "application/json"; + } else { + if (data instanceof w.File) { + isFile = true; + let ab = await new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener("load", (e) => { + resolve(e.target?.result as ArrayBuffer); + }); + reader.readAsArrayBuffer(data); + }); + if (ab) { + data = new File([ab], data.name); + } + } + } + + return data; + }; + + if (Array.isArray(data)) { + body = await Promise.all(data.map((e) => formatSingle(e))); + } else { + body = await formatSingle(data); + } + if (!isFile) { + body = JSON.stringify(body); + } + + return await sendRaw( + `${url.endsWith("/") ? url : `${url}/`}${ + uri.startsWith("/") ? uri.substring(1) : uri + }`, + data + ? { + method: "post", + headers, + body, + } + : {} + ); + }, + }; +}; + +export const fetchSendApi = async ( + _url: string, + params: any, + parentWindow?: any +) => { + let w: any = typeof window === "object" ? window : globalThis; + + const win = parentWindow || w; + let url = _url; + let frm: Awaited>; + if (!win.frmapi) { + win.frmapi = {}; + + win.frmapi[w.serverurl] = await createFrameCors(w.serverurl, win); + } + + frm = win.frmapi[w.serverurl]; + + if (url.startsWith("http")) { + const purl = new URL(url); + if (!win.frmapi[purl.host]) { + win.frmapi[purl.host] = await createFrameCors( + `${purl.protocol}//${purl.host}` + ); + } + + frm = win.frmapi[purl.host]; + url = url.substring(`${purl.protocol}//${purl.host}`.length); + } + if (!win.apiHeaders) { + win.apiHeaders = {}; + } + + if (!frm) { + await waitUntil(() => { + frm = win.frmapi[w.serverurl]; + return frm; + }); + } + + return await frm.send(url, params, win.apiHeaders); +}; diff --git a/pkgs/web-utils/src/define-window.ts b/pkgs/web-utils/src/define-window.ts index 0fb4e654..bc7aa88f 100644 --- a/pkgs/web-utils/src/define-window.ts +++ b/pkgs/web-utils/src/define-window.ts @@ -6,6 +6,11 @@ export const defineWindow = async (awaitServerUrl = true) => { if (awaitServerUrl) await waitUntil(() => w.__SRV_URL__); + w.prasiContext = { + global: {}, + render() {}, + }; + const location = window["location"]; const host = @@ -70,11 +75,10 @@ export const defineWindow = async (awaitServerUrl = true) => { } history.pushState({}, "", _href); - if (w.rootRes) w.rootRes.pathname = href; w.pathname = href; - if (w.rootRender) { - w.rootRender(); + if (w.prasiContext && w.prasiContext.render) { + w.prasiContext.render(); } }; diff --git a/pkgs/web-utils/src/export.ts b/pkgs/web-utils/src/export.ts index 6525a0f1..f77c7d8b 100644 --- a/pkgs/web-utils/src/export.ts +++ b/pkgs/web-utils/src/export.ts @@ -7,4 +7,7 @@ export * from "./page"; export * from "./global"; export * from "./define-react"; export * from "./define-window"; +export * from './client-api'; +export * from './client-frame'; +export * from './client-db'; export const React = _React; diff --git a/pkgs/web-utils/src/global.ts b/pkgs/web-utils/src/global.ts index 9797c034..e642be28 100644 --- a/pkgs/web-utils/src/global.ts +++ b/pkgs/web-utils/src/global.ts @@ -1,4 +1,12 @@ +import goober from "goober"; + declare global { const navigate: (path: string) => void; + const params: any; + const css: typeof goober.css; + const cx: (...arg: string[]) => string; + const api: any; + const db: any; + const prasiContext: any; } export {}; diff --git a/pkgs/web-utils/src/use-local.ts b/pkgs/web-utils/src/use-local.ts index 7442859c..55988f88 100644 --- a/pkgs/web-utils/src/use-local.ts +++ b/pkgs/web-utils/src/use-local.ts @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from "react"; - export const useLocal = ( data: T, effect?: (arg: {