feat(web): port Fase 5 static file server w/ traversal protection; feat(grpc): port health stub

This commit is contained in:
asepharyana
2026-09-02 22:50:38 +07:00
parent 7030e26b3a
commit 1a6268bde5
9 changed files with 237 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@zesdex/grpc",
"version": "1.21.2",
"private": true,
"type": "module",
"scripts": {
"grpc": "bun src/main.ts",
"build": "true"
},
"devDependencies": {
"@types/bun": "^1.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Zesdex gRPC interface package.
* Mirrors `apps/interfaces/grpc/`.
*/
export { startGrpcServer, handleGrpcRequest } from "./server.ts";
export type { GrpcState } from "./server.ts";
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
/**
* Zesdex gRPC server — standalone entry point (HTTP health stub).
* Mirrors the `--grpc` mode of the Rust gateway.
*/
import { startGrpcServer } from "./index.ts";
const port = Number.parseInt(process.env.ZESDEX_GRPC_PORT ?? "50051", 10);
startGrpcServer(port);
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bun
/**
* Zesdex gRPC interface — high-performance RPC with protobuf.
* Mirrors `apps/interfaces/grpc/src/lib.rs`.
*
* For now this crate provides a minimal HTTP health-check endpoint so
* consumers can verify the gRPC server is reachable. Full gRPC would use
* tonic+prost (Rust) — in Bun/TS a real gRPC server would use `@grpc/grpc-js`
* + generated protobuf types. This is intentionally a health stub.
*/
/** gRPC server state (minimal for health checks). */
export interface GrpcState {
version: string;
}
/** Build the gRPC health router. */
export function handleGrpcRequest(_state: GrpcState, req: Request): Response {
const url = new URL(req.url);
if (url.pathname === "/grpc/health") {
return new Response("gRPC server is running", {
status: 200,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
return new Response("Not found", { status: 404 });
}
/** Start the gRPC server (HTTP health stub). */
export function startGrpcServer(port: number): { stop: () => void } {
const state: GrpcState = { version: "1.21.2" };
const server = Bun.serve({
port,
fetch(req) {
return handleGrpcRequest(state, req);
},
});
console.log(`zesdex-grpc listening on http://0.0.0.0:${server.port}`);
console.log("Note: gRPC currently runs HTTP health endpoint. Add @grpc/grpc-js for full gRPC.");
return { stop: () => server.stop(true) };
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@zesdex/web",
"version": "1.21.2",
"private": true,
"type": "module",
"scripts": {
"web": "bun src/main.ts",
"build": "true"
},
"devDependencies": {
"@types/bun": "^1.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Zesdex web frontend interface package.
* Mirrors `apps/interfaces/web/`.
*/
export { startWebServer, handleWebRequest } from "./server.ts";
export type { WebState } from "./server.ts";
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bun
/**
* Zesdex web server — standalone entry point.
* Mirrors the `--web` mode of the Rust gateway.
*/
import { startWebServer } from "./index.ts";
const port = Number.parseInt(process.env.ZESDEX_WEB_PORT ?? "3000", 10);
const staticDir = process.env.ZESDEX_WEB_DIR ?? undefined;
startWebServer(port, staticDir);
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bun
/**
* Zesdex web frontend interface — serves static browser assets.
* Mirrors `apps/interfaces/web/src/lib.rs`.
*
* Serves files from a `dist/`/static directory with path-traversal protection:
* every requested path is canonicalized and must resolve inside `static_dir`.
* Falls back to a minimal placeholder page when no frontend is built.
*/
import * as fs from "node:fs";
import * as path from "node:path";
/** Web server state. */
export interface WebState {
static_dir: string;
}
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json",
".txt": "text/plain; charset=utf-8",
};
function mimeFor(file: string): string {
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
}
/** Placeholder page when no frontend is built. */
const PLACEHOLDER = `<!DOCTYPE html>
<html><head><title>Zesdex Web</title>
<meta charset="utf-8">
<style>body{font-family:sans-serif;padding:2em;background:#1a1b26;color:#c0caf5}
h1{color:#7aa2f7}a{color:#bb9af7}</style></head>
<body>
<h1>Zesdex Web</h1>
<p>Web interface is ready.</p>
<p>To connect the frontend:</p>
<ol>
<li>Build the frontend: <code>cd apps/interfaces/web && npm install && npm run build</code></li>
<li>Restart with <code>--web-dir apps/interfaces/web/dist</code></li>
</ol>
</body></html>`;
/** Resolve a requested path safely inside `staticDir`; returns null on traversal/not-found. */
function safeResolve(staticDir: string, rel: string): string | null {
// Strip any leading slash so a relative path is resolved against staticDir
// (path.resolve would otherwise ignore staticDir for absolute-ish inputs).
const clean = rel.replace(/^\/+/, "");
const candidate = path.resolve(staticDir, clean || "index.html");
const canonStatic = path.resolve(staticDir);
// Prevent directory traversal: resolved path must stay inside static dir.
if (!candidate.startsWith(canonStatic + path.sep) && candidate !== canonStatic) {
return null;
}
// Only serve regular files; reject if the target is a directory or missing.
try {
const st = fs.statSync(candidate);
return st.isFile() ? candidate : null;
} catch {
return null;
}
}
/** Build the web router (static file serving). */
export function handleWebRequest(state: WebState, req: Request): Response {
const url = new URL(req.url);
let rel = decodeURIComponent(url.pathname);
if (rel.endsWith("/")) rel += "index.html";
const file = safeResolve(state.static_dir, rel);
if (!file) {
// Root always returns the placeholder index (even without a built frontend).
if (rel === "/index.html") {
return new Response(PLACEHOLDER, {
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return new Response("Not found", { status: 404 });
}
const data = fs.readFileSync(file);
return new Response(data, {
status: 200,
headers: { "Content-Type": mimeFor(file) },
});
}
/** Start the web frontend server. */
export function startWebServer(port: number, staticDir?: string): { stop: () => void } {
const dir =
staticDir && staticDir !== ""
? staticDir
: (() => {
const p = path.resolve("apps/interfaces/web/dist");
return fs.existsSync(p) ? p : process.cwd();
})();
const state: WebState = { static_dir: dir };
const server = Bun.serve({
port,
fetch(req) {
return handleWebRequest(state, req);
},
});
console.log(`zesdex-web listening on http://0.0.0.0:${server.port} (dir: ${state.static_dir})`);
return { stop: () => server.stop(true) };
}
+18
View File
@@ -36,6 +36,20 @@
"@types/bun": "^1.2.0",
},
},
"apps/interfaces/grpc": {
"name": "@zesdex/grpc",
"version": "1.21.2",
"devDependencies": {
"@types/bun": "^1.2.0",
},
},
"apps/interfaces/web": {
"name": "@zesdex/web",
"version": "1.21.2",
"devDependencies": {
"@types/bun": "^1.2.0",
},
},
"apps/interfaces/ws": {
"name": "@zesdex/ws",
"version": "1.21.2",
@@ -121,8 +135,12 @@
"@zesdex/domain": ["@zesdex/domain@workspace:apps/packages/domain"],
"@zesdex/grpc": ["@zesdex/grpc@workspace:apps/interfaces/grpc"],
"@zesdex/infrastructure": ["@zesdex/infrastructure@workspace:apps/packages/infrastructure"],
"@zesdex/web": ["@zesdex/web@workspace:apps/interfaces/web"],
"@zesdex/ws": ["@zesdex/ws@workspace:apps/interfaces/ws"],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],