This commit is contained in:
Rizky 2024-02-14 21:40:12 +07:00
parent 2e6c0de9d5
commit 3b553b8349
10 changed files with 266 additions and 136 deletions

View File

@ -2,6 +2,7 @@ import { $ } from "execa";
import * as fs from "fs";
import { dirAsync, removeAsync, writeAsync } from "fs-jetpack";
import { apiContext } from "service-srv";
import { deploy } from "utils/deploy";
import { dir } from "utils/dir";
import { g } from "utils/global";
import { restartServer } from "utils/restart";
@ -29,14 +30,14 @@ export const _ = {
const path = dir(`app/web/`);
await dirAsync(path);
const web = g.web;
switch (action.type) {
case "check":
const deploys = fs.readdirSync(dir(`/app/web/deploy`));
return {
now: Date.now(),
current: 0,
deploys: [],
current: parseInt(g.deploy.config.deploy.ts),
deploys: deploys.map((e) => parseInt(e.replace(".gz", ""))),
db: {
url: g.dburl || "-",
},
@ -66,6 +67,7 @@ datasource db {
url = env("DATABASE_URL")
}`
);
await $({ cwd: dir("app/db") })`bun install`;
await $({ cwd: dir("app/db") })`bun prisma db pull`;
await $({ cwd: dir("app/db") })`bun prisma generate`;
@ -85,14 +87,13 @@ datasource db {
break;
case "deploy-del":
{
web.deploys = web.deploys.filter((e) => e !== parseInt(action.ts));
try {
await removeAsync(`${path}/deploys/${action.ts}`);
} catch (e) {}
await removeAsync(dir(`/app/web/deploy/${action.ts}.gz`));
const deploys = fs.readdirSync(dir(`/app/web/deploy`));
return {
now: Date.now(),
current: web.current,
deploys: web.deploys,
current: parseInt(deploy.config.deploy.ts),
deploys: deploys.map((e) => parseInt(e.replace(".gz", ""))),
};
}
break;
@ -100,56 +101,28 @@ datasource db {
break;
case "deploy":
{
await fs.promises.mkdir(`${path}/deploys`, { recursive: true });
const cur = Date.now();
const filePath = `${path}/deploys/${cur}`;
web.deploying = {
status: "generating",
received: 0,
total: 0,
};
if (
await downloadFile(action.dlurl, filePath, (rec, total) => {
web.deploying = {
status: "transfering",
received: rec,
total: total,
};
})
) {
web.deploying.status = "deploying";
await fs.promises.writeFile(`${path}/current`, cur.toString());
web.current = cur;
web.deploys.push(cur);
}
web.deploying = null;
deploy.config.deploy.ts = Date.now() + "";
await deploy.init();
const deploys = fs.readdirSync(dir(`/app/web/deploy`));
return {
now: Date.now(),
current: web.current,
deploys: web.deploys,
current: parseInt(deploy.config.deploy.ts),
deploys: deploys.map((e) => parseInt(e.replace(".gz", ""))),
};
}
break;
case "redeploy":
{
const cur = parseInt(action.ts);
const lastcur = web.current;
try {
if (web.deploys.find((e) => e === cur)) {
web.current = cur;
await fs.promises.writeFile(`${path}/current`, cur.toString());
}
} catch (e) {
web.current = lastcur;
web.deploys = web.deploys.filter((e) => e !== parseInt(action.ts));
await removeAsync(`${path}/deploys/${action.ts}`);
}
deploy.config.deploy.ts = action.ts;
await deploy.saveConfig();
await deploy.load(action.ts);
const deploys = fs.readdirSync(dir(`/app/web/deploy`));
return {
now: Date.now(),
current: web.current,
deploys: web.deploys,
current: parseInt(deploy.config.deploy.ts),
deploys: deploys.map((e) => parseInt(e.replace(".gz", ""))),
};
}
break;

View File

@ -2,6 +2,7 @@ import { readAsync } from "fs-jetpack";
import { apiContext } from "service-srv";
import { g } from "utils/global";
import { dir } from "utils/dir";
import { gzipAsync } from "utils/gzip";
const generated = {
"load.json": "",
@ -14,11 +15,83 @@ export const _ = {
async api() {
const { req, res } = apiContext(this);
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "content-type");
const gz = g.deploy.gz;
const parts = req.params._.split("/");
const action = {
_: () => {
res.send({ prasi: "v2" });
},
route: async () => {
if (gz) {
let layout = null as any;
for (const l of gz.layouts) {
if (!layout) layout = l;
if (l.is_default_layout) layout = l;
}
const result = await gzipAsync(
JSON.stringify({
site: { ...gz.site, api_url: (gz.site as any)?.config?.api_url },
urls: gz.pages.map((e) => {
return { id: e.id, url: e.url };
}),
layout,
})
);
return new Response(result, { headers: res.headers });
}
},
page: async () => {
const page = g.deploy.pages[parts[1]];
if (page) {
const result = await gzipAsync(
JSON.stringify({
id: page.id,
root: page.content_tree,
url: page.url,
})
);
return new Response(result, { headers: res.headers });
}
},
pages: async () => {
const pages = [];
if (req.params.ids) {
for (const id of req.params.ids) {
const page = g.deploy.pages[id];
if (page) {
pages.push({
id: page.id,
root: page.content_tree,
url: page.url,
});
}
}
}
return new Response(await gzipAsync(JSON.stringify(pages)), {
headers: res.headers,
});
},
comp: async () => {
const comps = {} as Record<string, any>;
if (req.params.ids) {
for (const id of req.params.ids) {
const comp = g.deploy.comps[id];
if (comp) {
comps[id] = comp;
}
}
}
return new Response(await gzipAsync(JSON.stringify(comps)), {
headers: res.headers,
});
},
"load.json": async () => {
res.setHeader("content-type", "application/json");
res.send(
@ -47,11 +120,11 @@ export const _ = {
},
};
const pathname: keyof typeof action = req.params._.split("/")[0] as any;
const pathname: keyof typeof action = parts[0] as any;
const run = action[pathname];
if (run) {
await run();
return await run();
}
},
};
@ -112,7 +185,7 @@ const getContent = async (type: keyof typeof generated, url?: string) => {
}
w.prasiApi[url] = {
apiEntry: ${JSON.stringify(getApiEntry())},
});
}
})();`;
}
return generated[type];

View File

@ -1,5 +1,6 @@
import { gzipSync } from "bun";
import brotliPromise from "brotli-wasm"; // Import the default export
import { simpleHash } from "utils/cache";
import { g } from "utils/global";
const brotli = await brotliPromise;
const parseQueryParams = (ctx: any) => {
@ -38,12 +39,6 @@ export const apiContext = (ctx: any) => {
};
};
const cache = {
gz: {} as Record<string, Uint8Array>,
br: {} as Record<string, Uint8Array>,
br_timeout: new Set<string>(),
};
export const createResponse = (
existingRes: any,
body: any,
@ -57,31 +52,18 @@ export const createResponse = (
if (cache_accept) {
const content_hash = simpleHash(content);
if (cache_accept.toLowerCase().includes("br")) {
if (cache.br[content_hash]) {
content = cache.br[content_hash];
if (g.cache.br[content_hash]) {
content = g.cache.br[content_hash];
headers["content-encoding"] = "br";
} else {
if (!cache.br_timeout.has(content_hash)) {
cache.br_timeout.add(content_hash);
if (!g.cache.br_timeout.has(content_hash)) {
g.cache.br_timeout.add(content_hash);
setTimeout(() => {
cache.br[content_hash] = brotli.compress(Buffer.from(content));
cache.br_timeout.delete(content_hash);
g.cache.br[content_hash] = brotli.compress(Buffer.from(content));
g.cache.br_timeout.delete(content_hash);
});
}
}
headers["content-encoding"] = "br";
}
if (
cache_accept.toLowerCase().includes("gz") &&
!headers["content-encoding"]
) {
if (cache.gz[content_hash]) {
content = cache.gz[content_hash];
} else {
cache.gz[content_hash] = gzipSync(content);
content = cache.gz[content_hash];
}
headers["content-encoding"] = "gzip";
}
}
@ -94,33 +76,17 @@ export const createResponse = (
: undefined
);
if (typeof body === "object") {
res.headers.append("content-type", "application/json");
}
for (const [k, v] of Object.entries(headers)) {
res.headers.append(k, v);
}
const cur = existingRes as Response;
cur.headers.forEach((value, key) => {
res.headers.append(key, value);
});
if (typeof body === "object" && !res.headers.has("content-type")) {
res.headers.append("content-type", "application/json");
}
return res;
};
export const simpleHash = (str: string, seed = 0) => {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString();
};

View File

@ -1,15 +1,20 @@
import { file } from "bun";
import { inspectAsync, listAsync } from "fs-jetpack";
import { existsAsync, inspectAsync, listAsync } from "fs-jetpack";
import { join } from "path";
import { createRouter } from "radix3";
import { dir } from "../utils/dir";
import { g } from "../utils/global";
import { parseArgs } from "./parse-args";
import { serveAPI } from "./serve-api";
import { serveWeb } from "./serve-web";
export const createServer = async () => {
g.router = createRouter({ strictTrailingSlash: true });
g.api = {};
g.cache = {
br: {},
br_timeout: new Set(),
};
const scan = async (path: string, root?: string) => {
const apis = await listAsync(path);
if (apis) {
@ -64,13 +69,59 @@ export const createServer = async () => {
return api;
}
if (g.deploy.gz && g.deploy.index) {
const core = g.deploy.gz.code.core;
const site = g.deploy.gz.code.site;
let pathname = url.pathname;
if (url.pathname[0] === "/") pathname = pathname.substring(1);
if (
!pathname ||
pathname === "index.html" ||
pathname === "index.htm"
) {
return await serveWeb({
content: g.deploy.index.render(),
pathname: "index.html",
});
}
let content = "";
if (core[pathname]) content = core[pathname];
else if (site[pathname]) content = site[pathname];
if (content) {
return await serveWeb({ content, pathname });
}
}
return new Response(`404 Not Found`, {
status: 404,
statusText: "Not Found",
});
};
if (g.deploy.gz?.code.server) {
if (!url.pathname.startsWith("/_deploy")) {
if (
!g.deploy.server &&
(await existsAsync(dir(`app/web/server/index.js`)))
) {
const res = require(dir(`app/web/server/index.js`));
if (res && res.server) {
g.deploy.server = res.server;
}
}
if (g.deploy.server && g.deploy.index) {
return await g.deploy.server.http({
handle,
mode: "prod",
req,
server: g.server,
url: { pathname: url.pathname, raw: url },
index: g.deploy.index,
});
}
}
return handle(req);

View File

@ -1,30 +1,11 @@
import { statSync } from "fs";
import { join } from "path";
import { dir } from "utils/dir";
import mime from "mime";
export const serveWeb = async (url: URL, req: Request) => {
return {};
};
export const generateIndexHtml = (base_url: string, site_id: string) => {
const base = base_url.endsWith("/")
? base_url.substring(0, base_url.length - 1)
: base_url;
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
<link rel="stylesheet" href="${base}/index.css?fresh">
</head>
<body class="flex-col flex-1 w-full min-h-screen flex opacity-0">
<div style="position:absolute;opacity:0.1;top:0;left:0">&nbsp;</div>
<div id="root"></div>
<script src="${base}/site.js" type="module"></script>
<script>window.id_site = "${site_id}";</script>
</body>
</html>`;
export const serveWeb = async (arg: { pathname: string; content: string }) => {
const type = mime.getType(arg.pathname);
return new Response(arg.content, {
headers: !type ? undefined : { "content-type": type },
});
};

15
pkgs/utils/cache.ts Normal file
View File

@ -0,0 +1,15 @@
export const simpleHash = (str: string, seed = 0) => {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString();
};

View File

@ -1,8 +1,9 @@
import { dirAsync, read } from "fs-jetpack";
import { dirAsync, read, removeAsync, writeAsync } from "fs-jetpack";
import { dir } from "./dir";
import { g } from "./global";
import { gunzipAsync } from "./gzip";
import { createRouter } from "radix3";
import { prodIndex } from "./prod-index";
const decoder = new TextDecoder();
export const deploy = {
@ -29,6 +30,8 @@ export const deploy = {
)
);
g.deploy.index = prodIndex(this.config.site_id);
if (g.deploy.gz) {
for (const page of g.deploy.gz.layouts) {
if (page.is_default_layout) {
@ -41,7 +44,9 @@ export const deploy = {
}
g.deploy.router = createRouter();
g.deploy.pages = {};
for (const page of g.deploy.gz.pages) {
g.deploy.pages[page.id] = page;
g.deploy.router.insert(page.url, page);
}
@ -49,6 +54,19 @@ export const deploy = {
for (const comp of g.deploy.gz.comps) {
g.deploy.comps[comp.id] = comp.content_tree;
}
if (g.deploy.gz.code.server) {
setTimeout(async () => {
if (g.deploy.gz) {
delete require.cache[dir(`app/web/server/index.js`)];
await removeAsync(dir(`app/web/server`));
await dirAsync(dir(`app/web/server`));
for (const [k, v] of Object.entries(g.deploy.gz.code.server)) {
await writeAsync(dir(`app/web/server/${k}`), v);
}
}
}, 300);
}
}
} catch (e) {
console.log("Failed to load site", this.config.site_id);
@ -82,11 +100,14 @@ export const deploy = {
g.deploy = {
comps: {},
layout: null,
pages: {},
router: createRouter(),
config: { deploy: { ts: "" }, site_id: "" },
init: false,
raw: null,
gz: null as any,
gz: null,
server: null,
index: null,
};
}

View File

@ -1,10 +1,11 @@
import { Server } from "bun";
import { Server, WebSocketHandler } from "bun";
import { Logger } from "pino";
import { RadixRouter } from "radix3";
import { PrismaClient } from "../../app/db/db";
import admin from "firebase-admin";
import { Database } from "bun:sqlite";
import { prodIndex } from "./prod-index";
type SingleRoute = {
url: string;
@ -13,6 +14,18 @@ type SingleRoute = {
path: string;
};
type PrasiServer = {
ws?: WebSocketHandler<{ url: string }>;
http: (arg: {
url: { raw: URL; pathname: string };
req: Request;
server: Server;
mode: "dev" | "prod";
handle: (req: Request) => Promise<Response>;
index: { head: string[]; body: string[]; render: () => string };
}) => Promise<Response>;
};
export const g = global as unknown as {
db: PrismaClient;
dburl: string;
@ -39,12 +52,20 @@ export const g = global as unknown as {
js: string;
etag: string;
};
cache: {
br: Record<string, Uint8Array>;
br_timeout: Set<string>;
};
deploy: {
init: boolean;
raw: any;
router: RadixRouter<{ url: string; id: string }>;
router?: RadixRouter<{ url: string; id: string }>;
layout: null | any;
comps: Record<string, any>;
pages: Record<
string,
{ id: string; url: string; name: true; content_tree: any }
>;
gz: null | {
layouts: {
id: string;
@ -66,5 +87,7 @@ export const g = global as unknown as {
site_id: string;
deploy: { ts: string };
};
server: PrasiServer | null;
index: ReturnType<typeof prodIndex> | null;
};
};

29
pkgs/utils/prod-index.ts Normal file
View File

@ -0,0 +1,29 @@
export const prodIndex = (site_id: string) => {
return {
head: [] as string[],
body: [] as string[],
render() {
return `\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=1.0, minimum-scale=1.0, maximum-scale=1.0">
<link rel="stylesheet" href="/index.css">
${this.head.join("\n")}
</head>
<body class="flex-col flex-1 w-full min-h-screen flex opacity-0">
${this.body.join("\n")}
<div id="root"></div>
<script>
window._prasi = { basepath: "/", site_id: "${site_id}" }
</script>
<script src="/main.js" type="module"></script>
</body>
</html>`;
},
};
};

View File

@ -1,6 +1,4 @@
const decoder = new TextDecoder();
function humanFileSize(bytes: any, si = false, dp = 1) {
export function humanFileSize(bytes: any, si = false, dp = 1) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {