This commit is contained in:
Rizky
2024-01-23 23:03:13 +07:00
commit 1bd21cf6a7
44 changed files with 2920 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { apiContext } from "service-srv";
import { g } from "utils/global";
export const _ = {
url: "/_api_frm",
async api() {
const { req, res } = apiContext(this);
let allowUrl = req.headers.get("origin") || req.headers.get("referer");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT");
res.setHeader("Access-Control-Allow-Headers", "content-type");
res.setHeader("Access-Control-Allow-Credentials", "true");
if (allowUrl) {
res.setHeader("Access-Control-Allow-Origin", allowUrl);
}
res.setHeader("content-type", "text/html");
res.setHeader("etag", g.frm.etag);
res.send(`<script>${g.frm.js}</script>`);
},
};
+20
View File
@@ -0,0 +1,20 @@
import { apiContext } from "service-srv";
import { execQuery } from "utils/query";
export const _ = {
url: "/_dbs/:dbName/:action",
async api(dbName: any, action?: string) {
const { req, res } = apiContext(this);
const body = req.params;
try {
const result = await execQuery(body, db);
res.send(result);
} catch (e: any) {
res.sendStatus(500);
res.send(e.message);
console.error(e);
}
},
};
+228
View File
@@ -0,0 +1,228 @@
import { $ } from "execa";
import * as fs from "fs";
import { dirAsync, removeAsync } from "fs-jetpack";
import { apiContext } from "service-srv";
import { dir } from "utils/dir";
import { g } from "utils/global";
import { restartServer } from "utils/restart";
import { loadWebCache } from "../server/load-web";
export const _ = {
url: "/_deploy",
async api(
action: (
| { type: "check" }
| { type: "db-update"; url: string }
| { type: "db-pull" }
| { type: "restart" }
| { type: "domain-add"; domain: string }
| { type: "domain-del"; domain: string }
| { type: "deploy-del"; ts: string }
| { type: "deploy"; dlurl: string }
| { type: "deploy-status" }
| { type: "redeploy"; ts: string }
) & {
id_site: string;
}
) {
const { res } = apiContext(this);
if (!g.web[action.id_site]) {
g.web[action.id_site] = {
current: 0,
domains: [],
deploying: null,
router: null,
deploys: [],
site_id: action.id_site,
cacheKey: 0,
cache: null,
};
}
const path = dir(`app/web/${action.id_site}`);
await dirAsync(path);
const web = g.web[action.id_site];
if (!web.domains) {
web.domains = [];
}
switch (action.type) {
case "check":
return {
now: Date.now(),
current: web.current,
deploys: web.deploys,
domains: web.domains,
db: {
url: g.dburl || "-",
},
};
case "db-update":
if (action.url) {
g.dburl = action.url;
await Bun.write(
dir("app/db/.env"),
`\
DATABASE_URL="${action.url}"
`
);
}
return "ok";
case "db-pull":
{
await $({ cwd: dir("app/db") })`bun prisma db pull`;
await $({ cwd: dir("app/db") })`bun prisma generate`;
res.send("ok");
setTimeout(() => {
restartServer();
}, 300);
}
break;
case "restart":
{
res.send("ok");
setTimeout(() => {
restartServer();
}, 300);
}
break;
case "domain-add":
{
web.domains.push(action.domain);
await Bun.write(`${path}/domains.json`, JSON.stringify(web.domains));
g.domains = null;
res.send("ok");
}
break;
case "domain-del":
{
web.domains = web.domains.filter((e) => e !== action.domain);
await Bun.write(`${path}/domains.json`, web.domains);
g.domains = null;
res.send("ok");
}
break;
case "deploy-del":
{
web.deploys = web.deploys.filter((e) => e !== parseInt(action.ts));
try {
await removeAsync(`${path}/deploys/${action.ts}`);
} catch (e) {}
return {
now: Date.now(),
current: web.current,
deploys: web.deploys,
};
}
break;
case "deploy-status":
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);
await loadWebCache(web.site_id, web.current);
}
web.deploying = null;
return {
now: Date.now(),
current: web.current,
deploys: web.deploys,
};
}
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());
await loadWebCache(web.site_id, web.current);
}
} catch (e) {
web.current = lastcur;
web.deploys = web.deploys.filter((e) => e !== parseInt(action.ts));
await removeAsync(`${path}/deploys/${action.ts}`);
}
return {
now: Date.now(),
current: web.current,
deploys: web.deploys,
};
}
break;
}
},
};
export const downloadFile = async (
url: string,
filePath: string,
progress?: (rec: number, total: number) => void
) => {
try {
const _url = new URL(url);
if (_url.hostname === "localhost") {
_url.hostname = "127.0.0.1";
}
const res = await fetch(_url);
if (res.body) {
const file = Bun.file(filePath);
const writer = file.writer();
const reader = res.body.getReader();
// Step 3: read the data
let receivedLength = 0; // received that many bytes at the moment
let chunks = []; // array of received binary chunks (comprises the body)
while (true) {
const { done, value } = await reader.read();
if (done) {
writer.end();
break;
}
chunks.push(value);
writer.write(value);
receivedLength += value.length;
if (progress) {
progress(
receivedLength,
parseInt(res.headers.get("content-length") || "0")
);
}
}
}
return true;
} catch (e) {
console.log(e);
return false;
}
};
+70
View File
@@ -0,0 +1,70 @@
import { apiContext } from "service-srv";
import { dir } from "utils/dir";
import { g } from "utils/global";
import { generateIndexHtml } from "../server/serve-web";
import mime from "mime";
export const _ = {
url: "/_file/**",
async api() {
const { req } = apiContext(this);
const rpath = decodeURIComponent(req.params._);
let res = new Response("NOT FOUND", { status: 404 });
try {
if (rpath.startsWith("site")) {
if (rpath === "site-html") {
res = new Response(generateIndexHtml("[[base_url]]", "[[site_id]]"));
}
if (rpath === "site-zip") {
const path = dir(`app/static/site.zip`);
res = new Response(Bun.file(path));
}
if (rpath === "site-md5") {
const path = dir(`app/static/md5`);
res = new Response(Bun.file(path));
}
} else if (rpath.startsWith("current-")) {
if (rpath.startsWith("current-md5-")) {
const site_id = rpath.substring("current-md5-".length);
const path = dir(`app/web/${site_id}/current`);
res = new Response(Bun.file(path));
} else {
const site_id = rpath.substring("current-".length);
const path = dir(`app/web/${site_id}/current`);
const id = await Bun.file(path).text();
if (id) {
const path = dir(`app/web/${site_id}/deploys/${id}`);
res = new Response(Bun.file(path));
}
}
}
} catch (e) {
res = new Response("NOT FOUND", { status: 404 });
}
const path = dir(`${g.datadir}/upload/${rpath}`);
const file = Bun.file(path);
if (await file.exists()) {
res = new Response(file);
} else {
res = new Response("NOT FOUND", { status: 404 });
}
const arr = path.split("-");
const ext = arr.pop();
const fname = arr.join("-") + "." + ext;
const ctype = mime.getType(fname);
if (ctype) {
res.headers.set("content-type", ctype);
}
res.headers.set("Access-Control-Allow-Origin", "*");
res.headers.set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT");
res.headers.set("Access-Control-Allow-Headers", "content-type");
res.headers.set("Access-Control-Allow-Credentials", "true");
return res;
},
};
+95
View File
@@ -0,0 +1,95 @@
import { Database } from "bun:sqlite";
import admin from "firebase-admin";
import { apiContext } from "service-srv";
import { dir } from "utils/dir";
import { g } from "utils/global";
export const _ = {
url: "/_notif/:action/:token",
async api(
action: string,
data:
| { type: "register"; token: string; id: string }
| { type: "send"; id: string; body: string; title: string; data?: any }
) {
const { req } = apiContext(this);
if (!g.firebaseInit) {
g.firebaseInit = true;
try {
g.firebase = admin.initializeApp({
credential: admin.credential.cert(dir("firebase-admin.json")),
});
g.notif = {
db: new Database(dir(`${g.datadir}/notif.sqlite`)),
};
g.notif.db.exec(`
CREATE TABLE IF NOT EXISTS notif (
token TEXT PRIMARY KEY,
id TEXT NOT NULL
);
`);
} catch (e) {
console.error(e);
}
}
if (g.firebase) {
switch (action) {
case "register":
{
if (data && data.type === "register" && data.id) {
if (data.token) {
const q = g.notif.db.query(
`SELECT * FROM notif WHERE token = '${data.token}'`
);
const result = q.all();
if (result.length > 0) {
g.notif.db.exec(
`UPDATE notif SET id = '${data.id}' WHERE token = '${data.token}'`
);
} else {
g.notif.db.exec(
`INSERT INTO notif VALUES ('${data.token}', '${data.id}')`
);
}
return { result: "OK" };
} else {
return { error: "missing token" };
}
}
}
break;
case "send":
{
if (data && data.type === "send") {
const q = g.notif.db.query<{ token: string }, any>(
`SELECT * FROM notif WHERE id = '${data.id}'`
);
let result = q.all();
for (const c of result) {
try {
await g.firebase.messaging().send({
notification: { body: data.body, title: data.title },
data: data.data,
token: c.token,
});
} catch (e) {
console.error(e);
result = result.filter((v) => v.token !== c.token);
}
}
return { result: "OK", totalDevice: result.length };
}
}
break;
}
}
return { error: "missing ./firebase-admin.json" };
},
};
+143
View File
@@ -0,0 +1,143 @@
import { readAsync } from "fs-jetpack";
import { apiContext } from "service-srv";
import { g } from "utils/global";
import { dir } from "utils/dir";
const cache = {
dev: "",
prod: "",
};
export const _ = {
url: "/_prasi/**",
async api() {
const { req, res } = apiContext(this);
res.setHeader("Access-Control-Allow-Origin", "*");
const action = {
_: () => {
res.send({ prasi: "v2" });
},
"load.json": async () => {
res.setHeader("content-type", "application/json");
res.send(
JSON.stringify({
apiEntry: getApiEntry(),
apiTypes: (await getApiTypes()) || "",
prismaTypes: {
"prisma.d.ts": await getPrisma("prisma"),
"runtime/index.d.ts": await getPrisma("runtime"),
"runtime/library.d.ts": await getPrisma("library"),
},
})
);
},
"load.js": async () => {
res.setHeader("content-type", "text/javascript");
const url = req.query_parameters["url"]
? JSON.stringify(req.query_parameters["url"])
: "undefined";
if (!cache.dev) {
cache.dev = `\
(() => {
const baseurl = new URL(location.href);
baseurl.pathname = '';
const url = ${url} || baseurl.toString();
const w = window;
if (!w.prasiApi) {
w.prasiApi = {};
}
w.prasiApi[url] = {
apiEntry: ${JSON.stringify(getApiEntry())},
apiTypes: ${JSON.stringify((await getApiTypes()) || "")},
prismaTypes: {
"prisma.d.ts": ${await getPrisma("prisma")},
"runtime/index.d.ts": ${await getPrisma("runtime")},
"runtime/library.d.ts": ${await getPrisma("library")},
},
};
})();`;
cache.prod = `\
(() => {
const baseurl = new URL(location.href);
baseurl.pathname = '';
const url = ${url} || baseurl.toString();
const w = window;
if (!w.prasiApi) {
w.prasiApi = {};
}
w.prasiApi[url] = {
apiEntry: ${JSON.stringify(getApiEntry())},
};
})();`;
}
if (req.query_parameters["dev"]) {
res.send(cache.dev);
} else {
res.send(cache.prod);
}
},
};
const pathname: keyof typeof action = req.params._.split("/")[0] as any;
const run = action[pathname];
if (run) {
await run();
}
},
};
export const getApiEntry = () => {
const res: any = {};
for (const [k, v] of Object.entries(g.api)) {
const name = k.substring(0, k.length - 3);
res[name] = { ...v, name, path: `app/srv/api/${v.path}` };
}
return res;
};
const getApiTypes = async () => {
return (
`\
declare module "gen/srv/api/entry" {
export * as srv from "gen/srv/api/srv";
}
` +
((await readAsync(dir("app/srv/exports.d.ts"))) || "")
.replace(/\"app\/srv\/api/gi, '"srv/api')
.replace(
'declare module "app/srv/exports"',
'declare module "gen/srv/api/srv"'
)
);
};
const getPrisma = async (path: string) => {
if (path === "prisma")
return JSON.stringify(
(
(await readAsync(dir("node_modules/.prisma/client/index.d.ts"))) || ""
).replace(`@prisma/client/runtime/library`, `./runtime/library`)
);
if (path === "runtime")
return JSON.stringify(
await readAsync(
dir("node_modules/@prisma/client/runtime/index-browser.d.ts")
)
);
if (path === "library")
return JSON.stringify(
await readAsync(dir("node_modules/@prisma/client/runtime/library.d.ts"))
);
return JSON.stringify({});
};
+41
View File
@@ -0,0 +1,41 @@
import { g } from "utils/global";
import { gzipAsync } from "utils/gzip";
export const _ = {
url: "/_proxy/*",
async api(arg: {
url: string;
method: "POST" | "GET";
headers: any;
body: any;
}) {
const res = await fetch(
arg.url,
arg.body
? {
method: arg.method || "POST",
headers: arg.headers,
body: arg.body,
}
: {
headers: arg.headers,
}
);
let body: any = null;
const headers: any = {};
res.headers.forEach((v, k) => {
headers[k] = v;
});
body = await res.arrayBuffer();
if (headers["content-encoding"] === "gzip") {
body = await gzipAsync(new Uint8Array(body));
} else {
delete headers["content-encoding"];
}
return new Response(body, { headers });
},
};
+36
View File
@@ -0,0 +1,36 @@
import mp from "@surfy/multipart-parser";
import { writeAsync } from "fs-jetpack";
import { apiContext } from "service-srv";
import { dir } from "utils/dir";
import { g } from "utils/global";
export const _ = {
url: "/_upload",
async api(body: any) {
const { req } = apiContext(this);
let url = "";
const raw = await req.arrayBuffer();
const parts = mp(Buffer.from(raw)) as Record<
string,
{ fileName: string; mime: string; type: string; buffer: Buffer }
>;
for (const [_, part] of Object.entries(parts)) {
const d = new Date();
const path = `${d.getFullYear()}-${d.getMonth()}/${d.getDate()}/${d.getTime()}-${part.fileName
?.replace(/[\W_]+/g, "-")
.toLowerCase()}`;
url = `/_file/${path}`;
await writeAsync(dir(`${g.datadir}/upload/${path}`), part.buffer);
}
return url;
},
};
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
);
}
+79
View File
@@ -0,0 +1,79 @@
import mime from "mime";
import { apiContext } from "service-srv";
import { g } from "utils/global";
import { getApiEntry } from "./_prasi";
export const _ = {
url: "/_web/:id/**",
async api(id: string, _: string) {
const { req, res } = apiContext(this);
const web = g.web[id];
if (web) {
const cache = web.cache;
if (cache) {
const parts = _.split("/");
switch (parts[0]) {
case "site": {
res.setHeader("content-type", "application/json");
if (req.query_parameters["prod"]) {
return {
site: cache.site,
pages: cache.pages.map((e) => {
return {
id: e.id,
url: e.url,
};
}),
api: getApiEntry(),
};
} else {
return cache.site;
}
}
case "pages": {
res.setHeader("content-type", "application/json");
return cache.pages.map((e) => {
return {
id: e.id,
url: e.url,
};
});
}
case "page": {
res.setHeader("content-type", "application/json");
return cache.pages.find((e) => e.id === parts[1]);
}
case "npm-site": {
let path = parts.slice(1).join("/");
res.setHeader("content-type", mime.getType(path) || "text/plain");
if (path === "site.js") {
path = "index.js";
}
return cache.npm.site[path];
}
case "npm-page": {
const page_id = parts[1];
if (cache.npm.pages[page_id]) {
let path = parts.slice(2).join("/");
res.setHeader("content-type", mime.getType(path) || "text/plain");
if (path === "page.js") {
path = "index.js";
}
return cache.npm.pages[page_id][path];
}
res.setHeader("content-type", "text/javascript");
}
case "comp": {
res.setHeader("content-type", "application/json");
return cache.comps.find((e) => e.id === parts[1]);
}
}
}
}
return req.params;
},
};
+35
View File
@@ -0,0 +1,35 @@
import { generateAPIFrm } from "./server/api-frm";
import { createServer } from "./server/create";
import { prepareAPITypes } from "./server/prep-api-ts";
import { config } from "./utils/config";
import { g } from "./utils/global";
import { createLogger } from "./utils/logger";
import { loadWeb } from "./server/load-web";
import { ensureNotRunning } from "utils/ensure";
import { preparePrisma } from "utils/prisma";
import { startDevWatcher } from "utils/dev-watcher";
g.mode = process.argv.includes("dev") ? "dev" : "prod";
g.datadir = g.mode === "prod" ? "../data" : ".data";
await preparePrisma();
await createLogger();
await ensureNotRunning();
if (g.db) {
await g.db.$connect();
}
await config.init();
await loadWeb();
g.log.info(g.mode === "dev" ? "DEVELOPMENT" : "PRODUCTION");
if (g.mode === "dev") {
await startDevWatcher();
}
await createServer();
await generateAPIFrm();
await prepareAPITypes();
+19
View File
@@ -0,0 +1,19 @@
{
"name": "pkgs",
"dependencies": {
"@surfy/multipart-parser": "^1.0.2",
"@swc/core": "^1.3.91",
"@types/mime": "^3.0.2",
"@types/unzipper": "^0.10.7",
"execa": "^8.0.1",
"fs-jetpack": "^5.1.0",
"lmdb": "^2.8.5",
"mime": "^3.0.0",
"pino": "^8.15.3",
"pino-pretty": "^10.2.0",
"radash": "^11.0.0",
"radix3": "^1.1.0",
"typescript": "^5.2.2",
"unzipper": "^0.10.14"
}
}
+62
View File
@@ -0,0 +1,62 @@
import { g } from "../utils/global";
const parseQueryParams = (ctx: any) => {
const pageHref = ctx.req.url;
const searchParams = new URLSearchParams(
pageHref.substring(pageHref.indexOf("?"))
);
const result: any = {};
searchParams.forEach((v, k) => {
result[k] = v;
});
return result as any;
};
export const apiContext = (ctx: any) => {
ctx.req.params = ctx.params;
ctx.req.query_parameters = parseQueryParams(ctx);
return {
req: ctx.req as Request & { params: any; query_parameters: any },
res: {
...ctx.res,
send: (body) => {
ctx.res = createResponse(ctx.res, body);
},
sendStatus: (code: number) => {
ctx.res._status = code;
},
setHeader: (key: string, value: string) => {
ctx.res.headers.append(key, value);
},
} as Response & {
send: (body?: string | object) => void;
setHeader: (key: string, value: string) => void;
sendStatus: (code: number) => void;
},
};
};
export const createResponse = (existingRes: any, body: any) => {
const status =
typeof existingRes._status === "number" ? existingRes._status : undefined;
let res = new Response(
typeof body === "string" ? body : JSON.stringify(body),
status
? {
status,
}
: undefined
);
if (typeof body === "object") {
res.headers.append("content-type", "application/json");
}
const cur = existingRes as Response;
for (const [key, value] of cur.headers.entries()) {
res.headers.append(key, value);
}
return res;
};
+70
View File
@@ -0,0 +1,70 @@
import { transform } from "@swc/core";
import { g } from "../utils/global";
import { createHash } from "crypto";
export const generateAPIFrm = async () => {
const res = await transform(
`
(BigInt.prototype).toJSON = function () {
return "BigInt::" + this.toString();
};
const replacer = (key, value) => {
if (typeof value === "string" && value.startsWith('BigInt::')) {
return BigInt(value.substr(8));
}
return value;
}
window.addEventListener('message', (e) => {
const msg = e.data;
const init = Object.assign({}, msg.init)
let input = msg.input;
let url = msg.input;
if (typeof msg.input === 'string') {
if (!input.startsWith('http')) {
url = new URL(\`\$\{location.origin\}\$\{input\}\`)
} else {
url = new URL(input)
}
}
if (init && init.body && typeof init.body === 'object') {
if (Array.isArray(init.body)) {
const body = new FormData();
body.append("file", init.body[0]);
init.body = body;
}
}
fetch(url.pathname, init)
.then(async (res) => {
if (res) {
const body = await res.text();
if (res.ok) {
try {
parent.postMessage({result: JSON.parse(body, replacer), id: msg.id }, '*')
} catch(e) {
parent.postMessage({result: body, id: msg.id }, '*')
}
} else {
try {
parent.postMessage({error: JSON.parse(body, replacer), id: msg.id }, '*')
} catch(e) {
parent.postMessage({error: body, id: msg.id }, '*')
}
}
}
})
})
parent.postMessage('initialized', '*')`,
{ minify: true }
);
g.frm = {
js: res.code,
etag: createHash("md5").update(res.code).digest("hex"),
};
};
+121
View File
@@ -0,0 +1,121 @@
import { inspectAsync, listAsync } from "fs-jetpack";
import { join } from "path";
import { createRouter } from "radix3";
import { g } from "../utils/global";
import { parseArgs } from "./parse-args";
import { serveAPI } from "./serve-api";
import { serveWeb } from "./serve-web";
import { dir } from "../utils/dir";
import { file } from "bun";
import { trim } from "radash";
export const createServer = async () => {
g.router = createRouter({ strictTrailingSlash: true });
g.api = {};
const scan = async (path: string, root?: string) => {
const apis = await listAsync(path);
if (apis) {
for (const filename of apis) {
const importPath = join(path, filename);
if (filename.endsWith(".ts")) {
try {
const api = await import(importPath);
let args: string[] = await parseArgs(importPath);
const route = {
url: api._.url,
args,
fn: api._.api,
path: importPath.substring((root || path).length + 1),
};
g.api[filename] = route;
g.router.insert(route.url, g.api[filename]);
} catch (e) {
g.log.warn(
`Failed to import app/srv/api${importPath.substring(
(root || path).length
)}`
);
const f = file(importPath);
if (f.size > 0) {
console.error(e);
} else {
g.log.warn(` ➨ file is empty`);
}
}
} else {
const dir = await inspectAsync(importPath);
if (dir?.type === "dir") {
await scan(importPath, path);
}
}
}
}
};
await scan(dir(`app/srv/api`));
await scan(dir(`pkgs/api`));
g.server = Bun.serve({
port: g.port,
async fetch(req) {
const url = new URL(req.url);
const web = await serveWeb(url, req);
let index = ["", ""];
if (web) {
if (Array.isArray(web)) index = web;
else {
return web;
}
}
const api = await serveAPI(url, req);
if (api) {
return api;
}
if (index) {
let status: any = {};
if (!["", "index.html"].includes(trim(url.pathname, " /"))) {
status = {
status: 404,
statusText: "Not Found",
};
}
const [site_id, body] = index;
if (g.web[site_id]) {
const router = g.web[site_id].router;
if (router) {
let found = router.lookup(url.pathname);
if (!found) {
found = router.lookup(url.pathname + "/");
}
if (found) {
status = {};
}
}
}
return new Response(body, {
...status,
headers: {
"content-type": "text/html",
},
});
}
return new Response(`404 Not Found`, {
status: 404,
statusText: "Not Found",
});
},
});
if (process.env.PRASI_MODE === "dev") {
g.log.info(`http://localhost:${g.server.port}`);
} else {
g.log.info(`Started at port: ${g.server.port}`);
}
};
+129
View File
@@ -0,0 +1,129 @@
import { file } from "bun";
import { $ } from "execa";
import {
dirAsync,
existsAsync,
inspectTreeAsync,
readAsync,
removeAsync,
writeAsync,
} from "fs-jetpack";
import { createRouter } from "radix3";
import { gunzipSync } from "zlib";
import { downloadFile } from "../api/_deploy";
import { dir } from "../utils/dir";
import { g } from "../utils/global";
export const loadWeb = async () => {
g.web = {};
await dirAsync(dir(`app/static`));
const siteZip = `${
g.mode === "dev" ? "http://localhost:4550" : "https://prasi.app"
}/site-bundle`;
const zipPath = dir(`app/static/site.zip`);
const md5Path = dir(`app/static/md5`);
if (!(await file(zipPath).exists()) || !(await file(md5Path).exists())) {
const md5 = await fetch(`${siteZip}/md5`);
await writeAsync(md5Path, await md5.text());
await new Promise<void>((r) => setTimeout(r, 1000));
await downloadFile(`${siteZip}/download`, zipPath);
await removeAsync(dir(`app/static/site`));
await $({ cwd: dir(`app/static`) })`unzip site.zip`;
} else {
const md5 = await fetch(`${siteZip}/md5`);
const md5txt = await md5.text();
if (md5txt !== (await readAsync(md5Path))) {
const e = await fetch(`${siteZip}/download`);
await removeAsync(dir(`app/static`));
await dirAsync(dir(`app/static`));
await downloadFile(`${siteZip}/download`, zipPath);
await writeAsync(md5Path, md5txt);
await $({ cwd: dir(`app/static`) })`unzip site.zip`;
}
}
const list = await inspectTreeAsync(dir(`app/web`));
for (const web of list?.children || []) {
if (web.type === "file") continue;
const deploy = web.children?.find((e) => e.name === "deploys");
if (!deploy) {
await dirAsync(dir(`app/web/${web.name}/deploys`));
}
g.web[web.name] = {
current: parseInt(
(await readAsync(dir(`app/web/${web.name}/current`))) || "0"
),
deploys: deploy ? deploy.children.map((e) => parseInt(e.name)) : [],
domains:
(await readAsync(dir(`app/web/${web.name}/domains.json`), "json")) ||
[],
site_id: web.name,
deploying: null,
cacheKey: 0,
router: null,
cache: null,
};
const cur = g.web[web.name];
if (!cur.deploys.includes(cur.current)) {
cur.current = 0;
}
if (cur.current) {
await loadWebCache(cur.site_id, cur.current);
}
}
};
const decoder = new TextDecoder();
export const loadWebCache = async (site_id: string, ts: number | string) => {
const web = g.web[site_id];
if (web) {
const path = dir(`app/web/${site_id}/deploys/${ts}`);
if (await existsAsync(path)) {
const fileContent = await readAsync(path, "buffer");
if (fileContent) {
console.log(
`Loading site ${site_id}: ${humanFileSize(fileContent.byteLength)}`
);
const res = gunzipSync(fileContent);
web.cache = JSON.parse(decoder.decode(res));
web.router = createRouter();
for (const p of web.cache?.pages || []) {
web.router.insert(p.url, p);
}
}
}
}
};
function humanFileSize(bytes: any, si = false, dp = 1) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + " B";
}
const units = si
? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
: ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
let u = -1;
const r = 10 ** dp;
do {
bytes /= thresh;
++u;
} while (
Math.round(Math.abs(bytes) * r) / r >= thresh &&
u < units.length - 1
);
return bytes.toFixed(dp) + " " + units[u];
}
+36
View File
@@ -0,0 +1,36 @@
import { parseFile } from "@swc/core";
import { readAsync } from "fs-jetpack";
export const parseArgs = async (path: string) => {
const res = await parseFile(path, { syntax: "typescript" });
const args: string[] = [];
for (const e of res.body) {
if (
e.type === "ExportDeclaration" &&
e.declaration.type === "VariableDeclaration"
) {
const declare = e.declaration.declarations[0];
if (
declare &&
declare.type === "VariableDeclarator" &&
declare.id.type === "Identifier" &&
declare.id.value === "_" &&
declare.init?.type === "ObjectExpression"
) {
for (const prop of declare.init.properties) {
if (prop.type === "MethodProperty") {
for (const param of prop.params) {
if (
param.type === "Parameter" &&
param.pat.type === "Identifier"
) {
args.push(param.pat.value);
}
}
}
}
}
}
}
return args;
};
+55
View File
@@ -0,0 +1,55 @@
import { spawnSync } from "bun";
import { existsAsync, readAsync } from "fs-jetpack";
import { dir } from "../utils/dir";
import { g } from "../utils/global";
export const prepareAPITypes = async () => {
const out: string[] = [];
for (const [k, v] of Object.entries(g.api)) {
const name = k.substring(0, k.length - 3).replace(/\W/gi, "_");
let p = {
path: `"app/srv/api/${v.path}"`,
handler: `"./api/${v.path.substring(0, v.path.length - 3)}"`,
};
if (!(await existsAsync(dir(p.path)))) {
p.path = `"pkgs/api/${v.path}"`;
p.handler = `"../../pkgs/api/${v.path.substring(0, v.path.length - 3)}"`;
}
out.push(`\
export const ${name} = {
name: "${name}",
url: "${v.url}",
path: "app/srv/api/${v.path}",
args: ${JSON.stringify(v.args)},
handler: import(${p.handler})
}`);
}
await Bun.write(dir(`app/srv/exports.ts`), out.join(`\n`));
const targetFile = dir("app/srv/exports.d.ts");
spawnSync(
[
dir("node_modules/.bin/tsc"),
dir("app/srv/exports.ts"),
"--declaration",
"--emitDeclarationOnly",
"--outFile",
targetFile,
],
{
cwd: dir(`node_modules/.bin`),
}
);
let res = await readAsync(targetFile);
if (res) {
res = res.replace('export * from "@prisma/client";', "");
res = res.replace("server: Server;", "");
res = res.replace(`import { PrismaClient } from "app/db/db";`, "");
res = res.replace(`db: PrismaClient;`, "");
await Bun.write(targetFile, res);
}
};
+71
View File
@@ -0,0 +1,71 @@
import { createResponse } from "service-srv";
import { g } from "../utils/global";
export const serveAPI = async (url: URL, req: Request) => {
let found = g.router.lookup(url.pathname);
if (!found?.url) {
if (!url.pathname.endsWith("/")) {
found = g.router.lookup(url.pathname + "/");
}
if (!found?.url) {
found = null;
}
}
if (found) {
const params = { ...found.params };
let args = found.args.map((e) => {
return params[e];
});
if (req.method !== "GET") {
if (!req.headers.get("content-type")?.startsWith("multipart/form-data")) {
try {
const json = await req.json();
if (typeof json === "object") {
if (Array.isArray(json)) {
args = json;
for (let i = 0; i < json.length; i++) {
const val = json[i];
if (found.args[i]) {
params[found.args[i]] = val;
}
}
} else {
for (const [k, v] of Object.entries(json)) {
params[k] = v;
}
for (const [k, v] of Object.entries(params)) {
const idx = found.args.findIndex((arg) => arg === k);
if (idx >= 0) {
args[idx] = v;
}
}
}
}
} catch (e) {}
}
}
const current = {
req,
res: new Response(),
...found,
params,
};
const finalResponse = await current.fn(...args);
if (finalResponse instanceof Response) {
return finalResponse;
}
if (finalResponse) {
return createResponse(current.res, finalResponse);
}
return current.res;
}
};
+104
View File
@@ -0,0 +1,104 @@
import { statSync } from "fs";
import { join } from "path";
import { g } from "../utils/global";
import { dir } from "utils/dir";
const index = {
html: "",
css: {
src: null as any,
encoding: "",
},
isFile: {} as Record<string, boolean>,
};
export const serveWeb = async (url: URL, req: Request) => {
const domain = url.hostname;
let site_id = "";
if (!g.domains) {
g.domains = {};
for (const web of Object.values(g.web)) {
for (const d of web.domains) {
const durl = new URL(d);
g.domains[durl.hostname] = web.site_id;
}
}
}
if (typeof g.domains[domain] === "undefined") {
g.domains[domain] = "";
}
site_id = g.domains[domain];
if (!site_id) {
return false;
}
const base = dir(`app/static/site`);
// const base = `/Users/r/Developer/prasi/.output/app/srv/site`;
let path = join(base, url.pathname);
if (url.pathname === "/site_id") {
return new Response(site_id);
}
if (url.pathname.startsWith("/index.css")) {
if (!index.css.src) {
const res = await fetch("https://prasi.app/index.css");
index.css.src = await res.arrayBuffer();
index.css.encoding = res.headers.get("content-encoding") || "";
}
return new Response(index.css.src, {
headers: {
"content-type": "text/css",
"content-encoding": index.css.encoding,
},
});
}
try {
if (typeof index.isFile[path] === "undefined") {
const s = statSync(path);
if (s.isFile()) {
index.isFile[path] = true;
return new Response(Bun.file(path));
}
} else if (index.isFile[path]) {
return new Response(Bun.file(path));
}
} catch (e) {}
if (!index.html) {
index.html = generateIndexHtml("", site_id);
}
return [site_id, index.html];
};
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>
<script
src="https://js.sentry-cdn.com/a2dfb8b1128f4018b83bdc9c08d18da2.min.js"
crossorigin="anonymous"
></script>
</body>
</html>`;
};
+31
View File
@@ -0,0 +1,31 @@
import { spawnSync } from "bun";
import { dirAsync, removeAsync } from "fs-jetpack";
import { dirname } from "path";
import unzipper from "unzipper";
import { dir } from "utils/dir";
const res = await fetch(
`https://github.com/avolut/prasi-api/archive/refs/heads/main.zip`,
{ method: "get" }
);
const data = await unzipper.Open.buffer(Buffer.from(await res.arrayBuffer()));
const promises: Promise<void>[] = [];
await removeAsync(dir("pkgs"));
for (const file of data.files) {
if (file.type === "File") {
const path = file.path.split("/").slice(1).join("/");
if (path === "tsconfig.json" || path.startsWith("pkgs")) {
promises.push(
new Promise<void>(async (done) => {
await dirAsync(dirname(dir(path)));
await Bun.write(dir(path), await file.buffer());
done();
})
);
}
}
}
await Promise.all(promises);
spawnSync({ cmd: ["bun", "install"], stdout: "inherit", stderr: "inherit" });
+34
View File
@@ -0,0 +1,34 @@
import { dirAsync, readAsync } from "fs-jetpack";
import { dir } from "./dir";
import { g } from "./global";
const _internal = { config: {} as any, writeTimeout: null as any };
export const config = {
init: async () => {
await dirAsync(dir(`${g.datadir}/config`));
await dirAsync(dir(`${g.datadir}/files`));
_internal.config =
(await readAsync(dir(`${g.datadir}/config/conf.json`), "json")) || {};
},
get all() {
return _internal.config;
},
get(key: string) {
if (key.endsWith("url")) {
if (!(_internal.config[key] instanceof URL)) {
_internal.config[key] = new URL(_internal.config[key] || "");
}
}
return _internal.config[key];
},
set(key: string, value: any) {
_internal.config[key] = value;
clearTimeout(_internal.writeTimeout);
_internal.writeTimeout = setTimeout(() => {
Bun.write(dir(`${g.datadir}/config/conf.json`), _internal.config);
}, 100);
},
};
+26
View File
@@ -0,0 +1,26 @@
import { file } from "bun";
import { watch } from "fs";
import { dir } from "./dir";
import { dirAsync } from "fs-jetpack";
export const startDevWatcher = async () => {
await dirAsync(dir(`app/srv/api`));
watch(dir(`app/srv/api`), async (event, filename) => {
const s = file(dir(`app/srv/api/${filename}`));
if (s.size === 0) {
await Bun.write(
`app/srv/api/${filename}`,
`\
import { apiContext } from "service-srv";
export const _ = {
url: "/${filename?.substring(0, filename.length - 3)})}",
async api() {
const { req, res } = apiContext(this);
return "This is ${filename}";
}
}`
);
}
});
};
+5
View File
@@ -0,0 +1,5 @@
import { join } from "path";
export const dir = (path: string) => {
return join(process.cwd(), path);
};
+37
View File
@@ -0,0 +1,37 @@
import { connect } from "bun";
import { g } from "./global";
export const ensureNotRunning = async () => {
await new Promise<void>(async (resolve) => {
const checkPort = () => {
return new Promise<boolean>(async (done) => {
try {
const s = await connect({
hostname: "0.0.0.0",
port: g.port,
socket: {
open(socket) {},
data(socket, data) {},
close(socket) {},
drain(socket) {},
error(socket, error) {},
},
});
s.end();
done(false);
} catch (e) {
done(true);
}
});
};
if (!(await checkPort())) {
g.log.warn(`Port ${process.env.PORT} is used, waiting...`);
setInterval(async () => {
if (await checkPort()) resolve();
}, 500);
} else {
resolve();
}
});
};
+88
View File
@@ -0,0 +1,88 @@
import { Server } 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";
type SingleRoute = {
url: string;
args: string[];
fn: (...arg: any[]) => Promise<any>;
path: string;
};
export const g = global as unknown as {
db: PrismaClient;
dburl: string;
datadir: string;
mode: "dev" | "prod";
server: Server;
log: Logger;
firebaseInit: boolean,
firebase: admin.app.App;
notif: {
db: Database;
};
api: Record<string, SingleRoute>;
domains: null | Record<string, string>;
web: Record<
string,
{
site_id: string;
current: number;
deploying: null | { status: string; received: number; total: number };
deploys: number[];
domains: string[];
router: null | RadixRouter<{ id: string }>;
cacheKey: number;
cache: null | {
site: {
id: string;
name: string;
favicon: string;
domain: string;
id_user: string;
created_at: Date | null;
id_org: string | null;
updated_at: Date | null;
responsive: string;
} | null;
pages: {
id: string;
name: string;
url: string;
content_tree: any;
id_site: string;
created_at: Date | null;
js_compiled: string | null;
js: string | null;
updated_at: Date | null;
id_folder: string | null;
is_deleted: boolean;
}[];
npm: {
site: Record<string, string>;
pages: Record<string, Record<string, string>>;
};
comps: {
id: string;
name: string;
content_tree: any;
created_at: Date | null;
updated_at: Date | null;
type: string;
id_component_group: string | null;
props: any;
}[];
};
}
>;
router: RadixRouter<SingleRoute>;
port: number;
frm: {
js: string;
etag: string;
};
};
+25
View File
@@ -0,0 +1,25 @@
import { gzip, gunzip } from "zlib";
export const gzipAsync = (bin: Uint8Array) => {
return new Promise<Buffer>((resolve, reject) => {
gzip(bin, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
});
});
};
export const gunzipAsync = (bin: Uint8Array) => {
return new Promise<Buffer>((resolve, reject) => {
gunzip(bin, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
};
+10
View File
@@ -0,0 +1,10 @@
import { pino } from "pino";
import { g } from "./global";
export const createLogger = async () => {
g.log = pino({
transport: {
target: "pino-pretty",
},
});
};
+17
View File
@@ -0,0 +1,17 @@
import { existsAsync } from "fs-jetpack";
import { dir } from "./dir";
import { $ } from "execa";
import { g } from "./global";
export const preparePrisma = async () => {
if (await existsAsync(dir("app/db/.env"))) {
if (!(await existsAsync(dir("node_modules/.prisma")))) {
await $({ cwd: dir(`app/db`) })`bun prisma generate`;
}
const { PrismaClient } = await import("../../app/db/db");
g.db = new PrismaClient();
}
g.dburl = process.env.DATABASE_URL || "";
g.port = parseInt(process.env.PORT || "3000");
};
+43
View File
@@ -0,0 +1,43 @@
export type DBArg = {
db: string;
table: string;
action: string;
params: any[];
};
export const execQuery = async (args: DBArg, prisma: any) => {
const { table, action, params } = args;
const tableInstance = prisma[table];
if (tableInstance) {
if (action === "query" && table.startsWith("$query")) {
try {
const q = params.shift();
q.sql = true;
Object.freeze(q);
return await tableInstance.bind(prisma)(q, ...params);
} catch (e) {
console.log(e);
return e;
}
}
const method = tableInstance[action];
if (method) {
try {
const result = await method(...params);
if (!result) {
return JSON.stringify(result);
}
return result;
} catch (e: any) {
throw new Error(e.message);
}
}
}
};
+10
View File
@@ -0,0 +1,10 @@
import { $ } from "execa";
import { g } from "./global";
export const restartServer = () => {
if (g.mode === "dev") {
$`bun ${g.mode}`;
}
process.exit(0);
};
+33
View File
@@ -0,0 +1,33 @@
export const waitUntil = (
condition: number | (() => any),
timeout?: number
) => {
return new Promise<void>(async (resolve) => {
if (typeof condition === "function") {
let tout = null as any;
if (timeout) {
tout = setTimeout(resolve, timeout);
}
if (await condition()) {
clearTimeout(tout);
resolve();
return;
}
let count = 0;
const c = setInterval(async () => {
if (await condition()) {
if (tout) clearTimeout(tout);
clearInterval(c);
resolve();
}
if (count > 100) {
clearInterval(c);
}
}, 10);
} else if (typeof condition === "number") {
setTimeout(() => {
resolve();
}, condition);
}
});
};