commit 5d731ee191fa6bfe39ba690e19164ba08f6488e6 Author: Rizky Date: Sun Dec 15 05:21:32 2024 +0700 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..700a722 --- /dev/null +++ b/.gitignore @@ -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/* \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..511ec13 --- /dev/null +++ b/.vscode/settings.json @@ -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": [] +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..614b7c0 Binary files /dev/null and b/bun.lockb differ diff --git a/internal/db/ensure.ts b/internal/db/ensure.ts new file mode 100644 index 0000000..eb8210c --- /dev/null +++ b/internal/db/ensure.ts @@ -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}`); + } + } + } +}; diff --git a/internal/deploy/download.ts b/internal/deploy/download.ts new file mode 100644 index 0000000..2977f50 --- /dev/null +++ b/internal/deploy/download.ts @@ -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; +}; diff --git a/internal/deploy/ensure.ts b/internal/deploy/ensure.ts new file mode 100644 index 0000000..196921d --- /dev/null +++ b/internal/deploy/ensure.ts @@ -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; +}; diff --git a/internal/deploy/load.ts b/internal/deploy/load.ts new file mode 100644 index 0000000..95be840 --- /dev/null +++ b/internal/deploy/load.ts @@ -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", + } + ); + } + } + } + } +}; diff --git a/internal/server/ensure.ts b/internal/server/ensure.ts new file mode 100644 index 0000000..a078fc2 --- /dev/null +++ b/internal/server/ensure.ts @@ -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", + }); + } +}; diff --git a/internal/server/server.ts b/internal/server/server.ts new file mode 100644 index 0000000..d468652 --- /dev/null +++ b/internal/server/server.ts @@ -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}]` + ); + } +}); diff --git a/internal/server/start.ts b/internal/server/start.ts new file mode 100644 index 0000000..1081ca0 --- /dev/null +++ b/internal/server/start.ts @@ -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", + }); +}; diff --git a/internal/supervisor.ts b/internal/supervisor.ts new file mode 100644 index 0000000..31babe3 --- /dev/null +++ b/internal/supervisor.ts @@ -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); + } +}); diff --git a/internal/utils/color.ts b/internal/utils/color.ts new file mode 100644 index 0000000..4f573e5 --- /dev/null +++ b/internal/utils/color.ts @@ -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`, +} diff --git a/internal/utils/config.ts b/internal/utils/config.ts new file mode 100644 index 0000000..3e91933 --- /dev/null +++ b/internal/utils/config.ts @@ -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; diff --git a/internal/utils/download.ts b/internal/utils/download.ts new file mode 100644 index 0000000..356c689 --- /dev/null +++ b/internal/utils/download.ts @@ -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; + } +}; diff --git a/internal/utils/fs.ts b/internal/utils/fs.ts new file mode 100644 index 0000000..92d534c --- /dev/null +++ b/internal/utils/fs.ts @@ -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; + 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; + 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: "", + }, + }, +}; diff --git a/internal/utils/global.ts b/internal/utils/global.ts new file mode 100644 index 0000000..5f84f2a --- /dev/null +++ b/internal/utils/global.ts @@ -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; + 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(); +}; diff --git a/internal/utils/log.ts b/internal/utils/log.ts new file mode 100644 index 0000000..0338c44 --- /dev/null +++ b/internal/utils/log.ts @@ -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}`); +}; diff --git a/internal/utils/spawn.ts b/internal/utils/spawn.ts new file mode 100644 index 0000000..605909e --- /dev/null +++ b/internal/utils/spawn.ts @@ -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, + 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, + }; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..4e8df9a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..28e7e0f --- /dev/null +++ b/tsconfig.json @@ -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 + } +}