diff --git a/pkgs/api/_deploy.ts b/pkgs/api/_deploy.ts index 5bcbf3a..2e915cf 100644 --- a/pkgs/api/_deploy.ts +++ b/pkgs/api/_deploy.ts @@ -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; diff --git a/pkgs/api/_prasi.ts b/pkgs/api/_prasi.ts index 0b3dd09..311f49e 100644 --- a/pkgs/api/_prasi.ts +++ b/pkgs/api/_prasi.ts @@ -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; + 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]; diff --git a/pkgs/server/api-ctx.ts b/pkgs/server/api-ctx.ts index c631f37..e15971d 100644 --- a/pkgs/server/api-ctx.ts +++ b/pkgs/server/api-ctx.ts @@ -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, - br: {} as Record, - br_timeout: new Set(), -}; - 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(); -}; diff --git a/pkgs/server/create.ts b/pkgs/server/create.ts index 040188d..9b35751 100644 --- a/pkgs/server/create.ts +++ b/pkgs/server/create.ts @@ -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); diff --git a/pkgs/server/serve-web.ts b/pkgs/server/serve-web.ts index 6b0f671..3bc5f38 100644 --- a/pkgs/server/serve-web.ts +++ b/pkgs/server/serve-web.ts @@ -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 ` - - - - - - - - - -
 
-
- - - -`; + + +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 }, + }); }; diff --git a/pkgs/utils/cache.ts b/pkgs/utils/cache.ts new file mode 100644 index 0000000..f964186 --- /dev/null +++ b/pkgs/utils/cache.ts @@ -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(); +}; diff --git a/pkgs/utils/deploy.ts b/pkgs/utils/deploy.ts index f4e6481..2745355 100644 --- a/pkgs/utils/deploy.ts +++ b/pkgs/utils/deploy.ts @@ -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, }; } diff --git a/pkgs/utils/global.ts b/pkgs/utils/global.ts index c292d8a..418b806 100644 --- a/pkgs/utils/global.ts +++ b/pkgs/utils/global.ts @@ -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; + index: { head: string[]; body: string[]; render: () => string }; + }) => Promise; +}; + 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; + br_timeout: Set; + }; deploy: { init: boolean; raw: any; - router: RadixRouter<{ url: string; id: string }>; + router?: RadixRouter<{ url: string; id: string }>; layout: null | any; comps: Record; + 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 | null; }; }; diff --git a/pkgs/utils/prod-index.ts b/pkgs/utils/prod-index.ts new file mode 100644 index 0000000..eeb5876 --- /dev/null +++ b/pkgs/utils/prod-index.ts @@ -0,0 +1,29 @@ +export const prodIndex = (site_id: string) => { + return { + head: [] as string[], + body: [] as string[], + render() { + return `\ + + + + + + + + ${this.head.join("\n")} + + + + ${this.body.join("\n")} +
+ + + +`; + }, + }; +}; diff --git a/pkgs/server/load-web.ts b/pkgs/utils/size.ts similarity index 83% rename from pkgs/server/load-web.ts rename to pkgs/utils/size.ts index 86f0b09..5136be5 100644 --- a/pkgs/server/load-web.ts +++ b/pkgs/utils/size.ts @@ -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) {