This commit is contained in:
Rizky 2024-12-15 05:21:32 +07:00
commit 5d731ee191
20 changed files with 836 additions and 0 deletions

178
.gitignore vendored Normal file
View File

@ -0,0 +1,178 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
site
site/*

18
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
".codesandbox": true,
".devcontainer": true,
".vscode": true,
"node_modules": true,
".gitignore": true,
"bun.lockb": true,
"port.json": true
},
"hide-files.files": []
}

BIN
bun.lockb Executable file

Binary file not shown.

53
internal/db/ensure.ts Normal file
View File

@ -0,0 +1,53 @@
import { $ } from "bun";
import { dirAsync, removeAsync } from "fs-jetpack";
import { c } from "utils/color";
import { config } from "utils/config";
import { fs } from "utils/fs";
import { g } from "utils/global";
import { dbLog } from "utils/log";
export const ensureDBReady = async () => {
if (!config.current?.db?.url) {
dbLog("Warning: db.url is empty. Please set it in site.json");
} else {
const db = config.current?.db;
if (db.orm === "prisma") {
const cwd = fs.path(`site:app/db`);
const url = new URL(db.url);
const host = `[${c.blue}${url.hostname}${c.esc}]`;
const db_type = `[${c.red}${url.protocol.slice(0, -1).toUpperCase()}${
c.esc
}]`;
if (!fs.exists("site:app/db")) {
dbLog(`Preparing PrismaDB ${db_type} on ${host} ${url.pathname}`);
await removeAsync(cwd);
await dirAsync(cwd);
await $`bun init .`.cwd(cwd).quiet();
await $`bun add prisma`.cwd(cwd).quiet();
await $`bun prisma init`.cwd(cwd).quiet();
dbLog(`PrismaDB created at ${cwd}`);
fs.write(`site:app/db/.env`, `DATABASE_URL=${db.url}`);
await $`bun prisma db pull`.cwd(cwd).quiet();
dbLog(`PrismaDB instrospected (db pull)`);
await $`bun prisma generate`.cwd(cwd).quiet();
dbLog(`PrismaDB ready`);
await fs.write(
`site:app/db/index.ts`,
`\
import { PrismaClient } from "@prisma/client/extension";
export const db = new PrismaClient();
`
);
} else {
await $`bun prisma generate`.cwd(cwd).quiet();
dbLog(`PrismaDB Ready: ${db_type} on ${host} ${url.pathname}`);
}
}
}
};

View File

@ -0,0 +1,21 @@
import { config } from "utils/config";
import { downloadFile } from "utils/download";
import { fs } from "utils/fs";
import { siteLog } from "utils/log";
export const downloadDeployedSite = async (site_id: string) => {
let base_url = "https://prasi.avolut.com";
const ts = Date.now();
siteLog("Downloading site deploy: ");
await downloadFile(
`${base_url}/prod-zip/${site_id}?ts=${ts}&msgpack=1`,
fs.path(`site:deploy/history/${ts}.gz`),
(rec, total) => {
if (rec % 10 === 0) process.stdout.write(".");
}
);
config.set("deploy.current", ts);
config.set("deploy.history", [...(config.current?.deploy.history || []), ts]);
await fs.copy(`site:deploy/history/${ts}.gz`, `site:deploy/current/${ts}.gz`);
return ts;
};

27
internal/deploy/ensure.ts Normal file
View File

@ -0,0 +1,27 @@
import { config } from "utils/config";
import { fs } from "utils/fs";
import { downloadDeployedSite } from "./download";
export const ensureDeployExists = async (site_id: string) => {
let download_deploy = false;
const ts = config.current?.deploy.current;
if (!ts) {
download_deploy = true;
} else if (!fs.exists(`site:deploy/current/${ts}.gz`)) {
if (fs.exists(`site:deploy/history/${ts}.gz`)) {
await fs.copy(
`site:deploy/history/${ts}.gz`,
`site:deploy/current/${ts}.gz`
);
} else {
download_deploy = true;
}
}
if (download_deploy) {
const ts = await downloadDeployedSite(site_id);
process.stdout.write("\n");
return ts;
}
return ts as number;
};

45
internal/deploy/load.ts Normal file
View File

@ -0,0 +1,45 @@
import { gunzipSync } from "bun";
import { removeAsync } from "fs-jetpack";
import get from "lodash.get";
import { decode } from "msgpackr";
import { config } from "utils/config";
import { fs } from "utils/fs";
import { g } from "utils/global";
export const loadCurrentDeploy = async (ts: number) => {
if (fs.exists(`site:deploy/current/${ts}.gz`)) {
await removeAsync(fs.path(`site:deploy/current/files`));
const content = decode(
gunzipSync(
new Uint8Array(
await Bun.file(fs.path(`site:deploy/current/${ts}.gz`)).arrayBuffer()
)
)
);
g.site = {
layouts: content.layouts,
pages: content.pages,
comps: content.comps,
db: config.current?.db,
info: content.site,
};
for (const key of ["public", "code.server", "code.site", "code.core"]) {
const files = get(content, key);
if (files) {
for (const [path, raw_content] of Object.entries(files)) {
const prefix = key.split(".").pop() || "";
await fs.write(
`site:deploy/current/files/${prefix ? `${prefix}/` : ""}${path}`,
raw_content,
{
mode: "raw",
}
);
}
}
}
}
};

33
internal/server/ensure.ts Normal file
View File

@ -0,0 +1,33 @@
import { $ } from "bun";
import { fs } from "utils/fs";
export const ensureServerReady = async (is_dev: boolean) => {
if (!fs.exists("site:app/package.json")) {
await fs.write(`site:app/package.json`, {
name: "prasi-app",
workspaces: ["db"],
dependencies: { db: "workspace:*" },
});
}
await $`bun i`.cwd(fs.path("site:app")).quiet().nothrow();
if (is_dev) {
const rebuild = async () => {
try {
await $`bun build --watch --target bun --entry ${fs.path(
"internal:server/server.ts"
)} --outdir ${fs.path("site:app")} --sourcemap=linked`.quiet();
} catch (e) {
rebuild();
}
};
rebuild();
} else {
await Bun.build({
target: "bun",
entrypoints: [fs.path(`internal:server/server.ts`)],
outdir: fs.path("site:app"),
sourcemap: "linked",
});
}
};

16
internal/server/server.ts Normal file
View File

@ -0,0 +1,16 @@
import { c } from "utils/color";
import { config } from "utils/config";
import { g, startup } from "utils/global";
import { siteLog } from "utils/log";
import { loadCurrentDeploy } from "../deploy/load";
startup("site", async () => {
await config.init("site:site.json");
const ts = config.current?.deploy.current;
if (ts) {
await loadCurrentDeploy(ts);
siteLog(
`Site Loaded [${c.green}${g.site.pages.length} pages${c.esc}] [${c.blue}${g.site.comps.length} components${c.esc}]`
);
}
});

11
internal/server/start.ts Normal file
View File

@ -0,0 +1,11 @@
import { fs } from "utils/fs";
import { g } from "utils/global";
import { spawn } from "utils/spawn";
export const startServer = (is_dev: boolean) => {
g.server = spawn({
cmd: is_dev ? "bun run --watch server.js" : "bun run server.js",
cwd: fs.path("site:app"),
mode: "passthrough",
});
};

27
internal/supervisor.ts Normal file
View File

@ -0,0 +1,27 @@
import { c } from "utils/color";
import { config } from "utils/config";
import { fs } from "utils/fs";
import { startup } from "utils/global";
import { siteLog } from "utils/log";
import { ensureDBReady } from "./db/ensure";
import { ensureDeployExists } from "./deploy/ensure";
import { ensureServerReady } from "./server/ensure";
import { startServer } from "./server/start";
const is_dev = process.argv.includes("--dev");
startup("supervisor", async () => {
console.log(`${c.green}Prasi Server:${c.esc} ${fs.path("site:")}`);
await config.init("site:site.json");
const site_id = config.get("site_id") as string;
if (!site_id) {
siteLog("No Site Loaded");
} else {
siteLog(`Site ID: ${site_id}`);
await ensureDeployExists(site_id);
await ensureServerReady(is_dev);
await ensureDBReady();
startServer(is_dev);
}
});

25
internal/utils/color.ts Normal file
View File

@ -0,0 +1,25 @@
export const c = {
esc: "\x1b[0m",
black: "\x1b[30m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
blackbg: "\x1b[40m",
redbg: "\x1b[41m",
greenbg: "\x1b[42m",
yellowbg: "\x1b[43m",
bluebg: "\x1b[44m",
magentabg: "\x1b[45m",
cyanbg: "\x1b[46m",
whitebg: "\x1b[47m",
bold: "\x1b[1m",
dim: "\x1b[2m",
italic: "\x1b[3m",
underline: "\x1b[4m",
rgb: (r: number, g: number, b: number) => `\x1b[38;2;${r};${g};${b}m`,
rgbbg: (r: number, g: number, b: number) => `\x1b[48;2;${r};${g};${b}m`,
}

39
internal/utils/config.ts Normal file
View File

@ -0,0 +1,39 @@
import { dirAsync } from "fs-jetpack";
import { fs } from "./fs";
import get from "lodash.get";
import set from "lodash.set";
export const config = {
async init(path: string) {
if (!fs.exists(path)) {
await fs.write(path, default_config);
}
const result = await fs.read(path, "json");
this.current = result as typeof default_config;
this.file_path = path;
return result as typeof default_config;
},
get(path: string) {
return get(this.current, path);
},
async set(path: string, value: any) {
set(this.current as any, path, value);
await fs.write(this.file_path, this.current);
},
file_path: "",
current: null as null | typeof default_config,
};
const default_config = {
site_id: "",
port: 0,
upload_path: "upload",
db: { orm: "prisma" as "prisma" | "prasi", url: "" },
deploy: {
current: 0,
history: [],
},
};
export type SiteConfig = typeof default_config;

View File

@ -0,0 +1,50 @@
import { dirAsync } from "fs-jetpack";
import { dirname } from "path";
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 as any);
if (res.body) {
await dirAsync(dirname(filePath));
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;
}
};

88
internal/utils/fs.ts Normal file
View File

@ -0,0 +1,88 @@
import { mkdirSync, statSync } from "fs";
import { copyAsync } from "fs-jetpack";
import { g } from "./global";
import { dirname, join } from "path";
const internal = Symbol("internal");
export const fs = {
exists(path: string) {
try {
const s = statSync(this.path(path));
return s.isDirectory() || s.isFile();
} catch (e) {}
return false;
},
path(path: string) {
const all_prefix = this[internal].prefix as Record<string, string>;
const prefix_key = Object.keys(all_prefix).find((e) => path.startsWith(e));
const prefix_path = all_prefix[prefix_key!];
if (prefix_key && prefix_path) {
return `${prefix_path}/${path.substring(prefix_key.length + 1)}`;
}
return path;
},
async copy(from: string, to: string) {
const from_dir = this.path(from);
const to_path = this.path(to);
const is_dir = statSync(from_dir).isDirectory();
if (is_dir && !this.exists(to)) {
mkdirSync(to_path, { recursive: true });
} else {
const to_dir = dirname(to_path);
if (!fs.exists(to_dir)) {
mkdirSync(to_dir, { recursive: true });
}
}
return await copyAsync(from_dir, to_path, { overwrite: true });
},
async modify(arg: {
path: string;
save: (content: any) => string | object | Promise<string | object>;
as?: "json" | "string";
}) {
const as = arg.as || arg.path.endsWith(".json") ? "json" : "string";
const content = await this.read(arg.path, as);
const result = await arg.save(content);
return await this.write(arg.path, result);
},
async read(path: string, as?: "json" | "string") {
const file = Bun.file(this.path(path));
if (as === "json") {
return await file.json();
}
return await file.text();
},
async write(
path: string,
data: any,
opt?: {
mode?: "json" | "raw";
}
) {
const file = Bun.file(this.path(path));
if (typeof data === "object" && opt?.mode !== "raw") {
return await Bun.write(file, JSON.stringify(data, null, 2), {
createPath: true,
});
}
return await Bun.write(file, data, {
createPath: true,
});
},
init() {
this[internal].prefix.site = join(g.dir.root, "site");
this[internal].prefix.internal = join(process.cwd(), "internal");
},
[internal]: {
prefix: {
site: "",
internal: "",
},
},
};

59
internal/utils/global.ts Normal file
View File

@ -0,0 +1,59 @@
import { join, resolve } from "path";
import { fs } from "./fs";
import type { SiteConfig } from "./config";
import type { spawn } from "./spawn";
if (!(globalThis as any).prasi) {
(globalThis as any).prasi = {};
}
export const g = (globalThis as any).prasi as unknown as {
dir: { root: string };
server: ReturnType<typeof spawn>;
site: {
db?: SiteConfig["db"];
layouts: {
id: string;
name: string;
url: string;
content_tree: any;
is_default_layout: boolean;
}[];
pages: {
id: string;
name: string;
url: string;
content_tree: any;
}[];
comps: {
id: string;
content_tree: any;
}[];
info: {
id: string;
name: string;
config?: {
api_url: string;
};
responsive: string;
domain: string;
};
};
};
export const startup = (mode: "supervisor" | "site", fn: () => void) => {
g.dir = { root: "" };
if (mode === "supervisor") {
const argv = process.argv.filter((e) => e !== "--dev");
if (argv.length > 2) {
g.dir.root = resolve(argv[2]);
} else {
g.dir.root = process.cwd();
}
} else {
g.dir.root = join(process.cwd(), "..", "..");
}
fs.init();
fn();
};

9
internal/utils/log.ts Normal file
View File

@ -0,0 +1,9 @@
import { c } from "./color";
export const siteLog = (msg: string) => {
console.log(`${c.magenta}[SITE]${c.esc} ${msg}`);
};
export const dbLog = (msg: string) => {
console.log(`${c.cyan}[ DB ]${c.esc} ${msg}`);
};

82
internal/utils/spawn.ts Normal file
View File

@ -0,0 +1,82 @@
import { spawn as bunSpawn, type Subprocess } from "bun";
import { Readable } from "node:stream";
export const spawn = (
arg: {
cmd: string;
cwd?: string;
log?: false | { max_lines: number };
ipc?(message: any, subprocess: Subprocess): void;
} & (
| {
onMessage: (arg: {
from: "stdout" | "stderr";
text: string;
raw: string;
}) => void;
mode?: "pipe";
}
| {
mode?: "passthrough";
}
)
) => {
const log = {
lines: 0,
text: [] as string[],
};
async function processStream(
stream: AsyncIterable<Buffer>,
from: "stderr" | "stdout"
) {
for await (const x of stream) {
const buf = x as Buffer;
const raw = buf.toString("utf-8");
const text = raw
.trim()
.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
""
);
if (arg.log) {
log.lines += 1;
log.text.push(text);
if (log.lines > arg.log.max_lines) {
log.text.shift();
}
}
const on_msg = (arg as any).onMessage;
if (arg.mode !== "passthrough" && on_msg) {
on_msg({ from: from, text, raw });
}
}
}
const is_piped = arg.mode === "pipe" || !arg.mode;
const proc = bunSpawn({
cmd: arg.cmd.split(" "),
cwd: arg.cwd,
env: { ...process.env, FORCE_COLOR: "1" },
...(is_piped
? { stderr: "pipe", stdout: "pipe" }
: { stderr: "inherit", stdout: "inherit" }),
...(arg.ipc ? { ipc: arg.ipc } : undefined),
});
if (is_piped) {
const stdout = Readable.fromWeb(proc.stdout as any);
const stderr = Readable.fromWeb(proc.stderr as any);
processStream(stdout, "stdout");
processStream(stderr, "stderr");
}
return {
process: proc,
exited: proc.exited,
log,
};
};

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "prasi-srv",
"module": "index.ts",
"type": "module",
"scripts": {
"dev": "bun run --watch internal/supervisor.ts --dev"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@types/lodash.get": "^4.4.9",
"@types/lodash.set": "^4.3.9",
"dayjs": "^1.11.13",
"fs-jetpack": "^5.1.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"msgpackr": "^1.11.2"
}
}

32
tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"exclude": ["site/*"],
"compilerOptions": {
"paths": {
"utils/*": ["./internal/utils/*"]
},
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}