diff --git a/bun.lockb b/bun.lockb index 614b7c0..2a15311 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/internal/content/content-deploy.ts b/internal/content/content-deploy.ts index ea73bf4..056de21 100644 --- a/internal/content/content-deploy.ts +++ b/internal/content/content-deploy.ts @@ -6,19 +6,6 @@ export const prasi_content_deploy: PrasiContent = { async prepare(site_id) { await ensureDeployExists(site_id); }, - async comps(comp_ids) { - return []; - }, - async file(url, options) { - return { body: "", compression: "none" }; - }, - async layouts() { - return []; - }, - async page_urls() { - return {}; - }, - async pages(page_ids) { - return []; - }, + async staticFile(ctx) {}, + async route(ctx) {}, }; diff --git a/internal/content/content-ipc.ts b/internal/content/content-ipc.ts index 3e46d8b..32eb190 100644 --- a/internal/content/content-ipc.ts +++ b/internal/content/content-ipc.ts @@ -1,22 +1,18 @@ +import { g } from "utils/global"; import type { PrasiContent } from "./types"; export const prasi_content_ipc: PrasiContent = { prepare(site_id) { console.log("mantap jiwa"); }, - async comps(comp_ids) { - return []; - }, - async file(url, options) { - return { body: "", compression: "none" }; - }, - async layouts() { - return []; - }, - async page_urls() { - return {}; - }, - async pages(page_ids) { - return []; + async staticFile(ctx) { + const asset = g.mode === "site" && g.ipc?.asset!; + if (asset) { + const response = asset.serve(ctx); + if (response) { + return response; + } + } }, + async route(ctx) {}, }; diff --git a/internal/content/content.ts b/internal/content/content.ts index a7ac73d..25a9029 100644 --- a/internal/content/content.ts +++ b/internal/content/content.ts @@ -1,5 +1,5 @@ import { g } from "utils/global"; -export const PrasiContent = () => { +export const prasiContent = () => { return g.mode === "site" ? g.content : null; }; diff --git a/internal/content/types.ts b/internal/content/types.ts index 17871a5..fd70112 100644 --- a/internal/content/types.ts +++ b/internal/content/types.ts @@ -1,15 +1,9 @@ +import type { ServerCtx } from "utils/server-ctx"; + export type PrasiContent = { prepare: (site_id: string) => void | Promise; - page_urls: () => Promise>; - pages: (page_ids: string[]) => Promise; - comps: (comp_ids: string[]) => Promise; - layouts: () => Promise; - file: ( - url: string, - options?: { - accept: ("gzip" | "br" | "zstd")[]; - } - ) => Promise<{ body: any; compression: "none" | "gzip" | "br" | "zstd" }>; + staticFile: (ctx: ServerCtx) => Promise; + route: (ctx: ServerCtx) => Promise; }; export type ILayout = { diff --git a/internal/server/server.ts b/internal/server/server.ts index 0ffee2e..771e6d4 100644 --- a/internal/server/server.ts +++ b/internal/server/server.ts @@ -4,6 +4,9 @@ import { prasi_content_ipc } from "../content/content-ipc"; import { prasi_content_deploy } from "../content/content-deploy"; import { loadCurrentDeploy } from "../content/deploy/load"; import { ipcSend } from "../content/ipc/send"; +import { staticFile } from "utils/static"; +import type { ServerCtx } from "utils/server-ctx"; +import { prasiContent } from "../content/content"; startup("site", async () => { await config.init("site:site.json"); @@ -12,28 +15,63 @@ startup("site", async () => { if (g.ipc) { ipcSend({ type: "init" }); - process.on("message", (msg: { type: "start" }) => { - if (g.mode === "site") { - if (msg.type === "start") { - g.server = Bun.serve({ - fetch(request, server) {}, - websocket: { message(ws, message) {} }, - port: 0, - }); - ipcSend({ type: "ready", port: g.server.port }); + if (g.server) { + console.log("restarting..."); + process.exit(); + } else { + process.on( + "message", + async (msg: { type: "start"; path: { asset: string } }) => { + if (g.mode === "site" && g.ipc) { + if (msg.type === "start") { + g.ipc.asset = await staticFile(msg.path.asset); + startGlobalServer(); + ipcSend({ type: "ready", port: g.server.port }); + } + } } - } - }); + ); + } } else { const ts = config.current?.deploy.current; if (ts) { await loadCurrentDeploy(ts); } - g.server = Bun.serve({ - fetch(request, server) {}, - websocket: { message(ws, message) {} }, - }); + startGlobalServer(); } } }); + +const startGlobalServer = async () => { + if (g.mode === "site") { + g.server = Bun.serve({ + async fetch(req, server) { + const content = prasiContent(); + if (content) { + const url = new URL(req.url); + const pathname = req.url.split(url.host).pop() || ""; + const ctx: ServerCtx = { + server, + req: req, + url: { pathname, raw: url }, + }; + + const response = await content.staticFile(ctx); + if (response) { + return response; + } + + const routed = await content.route(ctx); + if (routed) { + return routed; + } + + return new Response("Not Found", { status: 404 }); + } + }, + websocket: { message(ws, message) {} }, + port: 0, + }); + } +}; diff --git a/internal/supervisor.ts b/internal/supervisor.ts index 6bca10c..285e30a 100644 --- a/internal/supervisor.ts +++ b/internal/supervisor.ts @@ -26,7 +26,7 @@ startup("supervisor", async () => { await ensureDBReady(); } else { g.mode = "site"; - if (g.mode === "site") g.ipc = true; + if (g.mode === "site") g.ipc = {}; } await ensureServerReady(is_dev); diff --git a/internal/utils/global.ts b/internal/utils/global.ts index 556bd42..80bcc75 100644 --- a/internal/utils/global.ts +++ b/internal/utils/global.ts @@ -5,6 +5,7 @@ import { fs } from "./fs"; import type { PrasiSpawn, spawn } from "./spawn"; import type { prasi_content_ipc } from "../content/content-ipc"; import type { prasi_content_deploy } from "../content/content-deploy"; +import type { StaticFile } from "./static"; if (!(globalThis as any).prasi) { (globalThis as any).prasi = {}; @@ -16,7 +17,10 @@ export const g = (globalThis as any).prasi as unknown as { | { mode: "site"; server: Server; - ipc: boolean; + ipc?: { + asset?: StaticFile; + }; + static_cache: any; content: typeof prasi_content_ipc & typeof prasi_content_deploy; site?: { db?: SiteConfig["db"]; diff --git a/internal/utils/server-ctx.ts b/internal/utils/server-ctx.ts new file mode 100644 index 0000000..8044867 --- /dev/null +++ b/internal/utils/server-ctx.ts @@ -0,0 +1,10 @@ +import type { Serve } from "bun"; + +export type ServerCtx = { + server: Serve; + url: { + pathname: string; + raw: URL; + }; + req: Request; +}; diff --git a/internal/utils/static.ts b/internal/utils/static.ts new file mode 100644 index 0000000..4e37219 --- /dev/null +++ b/internal/utils/static.ts @@ -0,0 +1,181 @@ +import * as zstd from "@bokuweb/zstd-wasm"; +import { Glob, gzipSync } from "bun"; +import { BunSqliteKeyValue } from "bun-sqlite-key-value"; +import { exists, existsAsync } from "fs-jetpack"; +import mime from "mime"; +import { readFileSync } from "node:fs"; +import { join } from "path"; +import { addRoute, createRouter, findRoute } from "rou3"; +import type { ServerCtx } from "./server-ctx"; +import { g } from "./global"; +import { waitUntil } from "./wait-until"; + +await zstd.init(); + +export type StaticFile = Awaited>; +export const staticFile = async ( + path: string, + opt?: { index?: string; debug?: boolean } +) => { + if (g.mode !== "site") return; + + if (!g.static_cache) { + g.static_cache = {} as any; + + if (!g.static_cache.gz) { + g.static_cache.gz = new BunSqliteKeyValue(":memory:"); + } + + if (!g.static_cache.zstd) { + g.static_cache.zstd = new BunSqliteKeyValue(":memory:"); + } + } + + const store = g.static_cache; + + const glob = new Glob("**"); + + const internal = { + indexPath: "", + rescan_timeout: null as any, + router: createRouter<{ + mime: string | null; + fullpath: string; + path: string; + }>(), + }; + const static_file = { + scanning: false, + paths: new Set(), + // rescan will be overwritten below. + async rescan(arg?: { immediatly?: boolean }) {}, + exists(rpath: string, arg?: { prefix?: string; debug?: boolean }) { + let pathname = rpath; + if (arg?.prefix && pathname) { + pathname = pathname.substring(arg.prefix.length); + } + const found = findRoute(internal.router, undefined, path + pathname); + return !!found; + }, + serve: (ctx: ServerCtx, arg?: { prefix?: string; debug?: boolean }) => { + let pathname = ctx.url.pathname || ""; + if (arg?.prefix && pathname) { + pathname = pathname.substring(arg.prefix.length); + } + const found = findRoute(internal.router, undefined, pathname); + + if (found) { + const { fullpath, mime } = found.data; + if (exists(fullpath)) { + const { headers, content } = cachedResponse( + ctx, + fullpath, + mime, + store + ); + headers["cache-control"] = "public, max-age=604800, immutable"; + return new Response(content, { + headers, + }); + } else { + store.gz.delete(fullpath); + store.zstd.delete(fullpath); + } + } + + if (opt?.index) { + const { headers, content } = cachedResponse( + ctx, + internal.indexPath, + "text/html", + store + ); + return new Response(content, { headers }); + } + }, + }; + + const scan = async () => { + if (static_file.scanning) { + await waitUntil(() => !static_file.scanning); + return; + } + static_file.scanning = true; + if (await existsAsync(path)) { + if (static_file.paths.size > 0) { + store.gz.delete([...static_file.paths]); + store.zstd.delete([...static_file.paths]); + } + + for await (const file of glob.scan(path)) { + if (file === opt?.index) internal.indexPath = join(path, file); + + static_file.paths.add(join(path, file)); + + let type = mime.getType(file); + if (file.endsWith(".ts")) { + type = "application/javascript"; + } + + addRoute(internal.router, undefined, `/${file}`, { + mime: type, + path: file, + fullpath: join(path, file), + }); + } + } + static_file.scanning = false; + }; + await scan(); + + static_file.rescan = (arg?: { immediatly?: boolean }) => { + return new Promise((resolve) => { + clearTimeout(internal.rescan_timeout); + internal.rescan_timeout = setTimeout( + async () => { + await scan(); + resolve(); + }, + arg?.immediatly ? 0 : 300 + ); + }); + }; + + return static_file; +}; + +const cachedResponse = ( + ctx: ServerCtx, + file_path: string, + mime: string | null, + store: any +) => { + const accept = ctx.req.headers.get("accept-encoding") || ""; + const headers: any = { + "content-type": mime || "", + }; + let content = null as any; + + if (accept.includes("zstd")) { + content = store.zstd.get(file_path); + if (!content) { + content = zstd.compress( + new Uint8Array(readFileSync(file_path)) as any, + 10 + ); + store.zstd.set(file_path, content); + } + headers["content-encoding"] = "zstd"; + } + + if (!content && accept.includes("gz")) { + content = store.gz.get(file_path); + if (!content) { + content = gzipSync(new Uint8Array(readFileSync(file_path))); + store.gz.set(file_path, content); + } + headers["content-encoding"] = "gzip"; + } + + return { content, headers }; +}; diff --git a/internal/utils/wait-until.ts b/internal/utils/wait-until.ts new file mode 100644 index 0000000..1f1ede4 --- /dev/null +++ b/internal/utils/wait-until.ts @@ -0,0 +1,33 @@ +export const waitUntil = ( + condition: number | (() => any), + arg?: { timeout?: number; interval?: number } +) => { + return new Promise(async (resolve) => { + if (typeof condition === "function") { + let tout = null as any; + if (arg?.timeout) { + tout = setTimeout(resolve, arg?.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); + } + }, arg?.interval || 10); + } else if (typeof condition === "number") { + setTimeout(() => { + resolve(); + }, condition); + } + }); +}; diff --git a/package.json b/package.json index 65c5e58..8177c79 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "bun run --watch internal/supervisor.ts --dev", - "ipc": "bun run --watch internal/supervisor.ts --dev --ipc" + "ipc": "bun run --hot internal/supervisor.ts --dev --ipc" }, "devDependencies": { "@types/bun": "latest" @@ -13,12 +13,16 @@ "typescript": "^5.0.0" }, "dependencies": { + "@bokuweb/zstd-wasm": "^0.0.22", "@types/lodash.get": "^4.4.9", "@types/lodash.set": "^4.3.9", + "bun-sqlite-key-value": "^1.13.1", "dayjs": "^1.11.13", "fs-jetpack": "^5.1.0", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "msgpackr": "^1.11.2" + "mime": "^4.0.6", + "msgpackr": "^1.11.2", + "rou3": "^0.5.1" } }