mirror of
https://github.com/furyhawk/bus_sg_app.git
synced 2026-07-21 02:06:48 +00:00
Initial commit: add core application files including README, server, frontend, and styles
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Bus SG App
|
||||
|
||||
A lightweight bus transport web app that uses the gateway OpenAPI contract at:
|
||||
|
||||
- http://localhost:8067/openapi.json
|
||||
|
||||
The frontend dynamically loads transport endpoints from the OpenAPI spec and calls them through a local proxy server.
|
||||
|
||||
## Run
|
||||
|
||||
1. Ensure your API gateway is running at http://localhost:8067.
|
||||
2. Start this app:
|
||||
|
||||
```bash
|
||||
bun run start
|
||||
```
|
||||
|
||||
3. Open:
|
||||
|
||||
- http://localhost:3000
|
||||
|
||||
## Features
|
||||
|
||||
- Dynamic endpoint explorer built from OpenAPI paths under /api/v1/*
|
||||
- Bus-focused endpoint filtering (Bus and Passenger Volume tags)
|
||||
- Quick bus-arrival panel with BusStopCode and optional ServiceNo
|
||||
- JSON response viewer
|
||||
- Arrival board card view when the response includes Services
|
||||
|
||||
## Config
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `PORT` (default: `3000`)
|
||||
- `API_ORIGIN` (default: `http://localhost:8067`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
API_ORIGIN=http://localhost:8067 PORT=3001 bun run start
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "bus-sg-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Singapore bus transport app powered by OpenAPI gateway",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "bun server.js"
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
const fallbackEndpointOptions = [
|
||||
{ label: "Bus Arrivals", path: "/api/v1/bus-arrival", method: "GET" },
|
||||
{ label: "Bus Services", path: "/api/v1/bus-services", method: "GET" },
|
||||
{ label: "Bus Routes", path: "/api/v1/bus-routes", method: "GET" },
|
||||
{ label: "Bus Stops", path: "/api/v1/bus-stops", method: "GET" },
|
||||
{ label: "Planned Bus Routes", path: "/api/v1/planned-bus-routes", method: "GET" },
|
||||
{ label: "Passenger Volume (Bus)", path: "/api/v1/passenger-volume/bus", method: "GET" },
|
||||
{ label: "Passenger Volume (OD Bus)", path: "/api/v1/passenger-volume/od-bus", method: "GET" }
|
||||
];
|
||||
|
||||
let endpointOptions = [...fallbackEndpointOptions];
|
||||
|
||||
const endpointEl = document.querySelector("#endpoint");
|
||||
const methodEl = document.querySelector("#method");
|
||||
const paramsEl = document.querySelector("#params");
|
||||
const responseEl = document.querySelector("#response");
|
||||
const statusPillEl = document.querySelector("#status-pill");
|
||||
const arrivalBoardEl = document.querySelector("#arrival-board");
|
||||
|
||||
const addParamBtn = document.querySelector("#add-param");
|
||||
const sendRequestBtn = document.querySelector("#send-request");
|
||||
const runArrivalBtn = document.querySelector("#run-arrival");
|
||||
|
||||
const busStopCodeEl = document.querySelector("#bus-stop-code");
|
||||
const serviceNoEl = document.querySelector("#service-no");
|
||||
|
||||
function setStatus(text, state) {
|
||||
statusPillEl.textContent = text;
|
||||
statusPillEl.classList.remove("ok", "error");
|
||||
if (state) {
|
||||
statusPillEl.classList.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
function addParamRow(key = "", value = "") {
|
||||
const template = document.querySelector("#param-template");
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
|
||||
const keyEl = row.querySelector(".param-key");
|
||||
const valueEl = row.querySelector(".param-value");
|
||||
const removeBtn = row.querySelector(".remove-param");
|
||||
|
||||
keyEl.value = key;
|
||||
valueEl.value = value;
|
||||
|
||||
removeBtn.addEventListener("click", () => row.remove());
|
||||
paramsEl.appendChild(row);
|
||||
}
|
||||
|
||||
function getSelectedEndpoint() {
|
||||
const index = Number(endpointEl.value);
|
||||
return endpointOptions[index] || endpointOptions[0];
|
||||
}
|
||||
|
||||
function titleizePath(path) {
|
||||
return path
|
||||
.replace("/api/v1/", "")
|
||||
.split("/")
|
||||
.map((part) => part.replace(/-/g, " "))
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" - ");
|
||||
}
|
||||
|
||||
async function loadOpenApiEndpoints() {
|
||||
try {
|
||||
const response = await fetch("/openapi.json");
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = await response.json();
|
||||
const paths = spec && spec.paths ? spec.paths : {};
|
||||
const discovered = [];
|
||||
|
||||
Object.entries(paths).forEach(([path, methods]) => {
|
||||
if (!path.startsWith("/api/v1/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getMethod = methods && methods.get;
|
||||
if (!getMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(getMethod.tags) ? getMethod.tags.join(" ").toLowerCase() : "";
|
||||
const include = tags.includes("bus") || tags.includes("passenger") || path.includes("bus");
|
||||
if (!include) {
|
||||
return;
|
||||
}
|
||||
|
||||
discovered.push({
|
||||
label: getMethod.summary || titleizePath(path),
|
||||
path,
|
||||
method: "GET"
|
||||
});
|
||||
});
|
||||
|
||||
if (discovered.length > 0) {
|
||||
endpointOptions = discovered.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback endpoint options when OpenAPI loading fails.
|
||||
}
|
||||
}
|
||||
|
||||
function collectParams() {
|
||||
const rows = [...paramsEl.querySelectorAll(".param-row")];
|
||||
const params = new URLSearchParams();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const key = row.querySelector(".param-key").value.trim();
|
||||
const value = row.querySelector(".param-value").value.trim();
|
||||
if (key) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function minutesUntil(isoValue) {
|
||||
if (!isoValue) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
const target = new Date(isoValue);
|
||||
if (Number.isNaN(target.getTime())) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
const deltaMs = target.getTime() - Date.now();
|
||||
const mins = Math.max(0, Math.round(deltaMs / 60000));
|
||||
return `${mins} min`;
|
||||
}
|
||||
|
||||
function renderArrivalBoard(payload) {
|
||||
arrivalBoardEl.innerHTML = "";
|
||||
const services = payload && payload.Services;
|
||||
|
||||
if (!Array.isArray(services) || services.length === 0) {
|
||||
arrivalBoardEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
services.slice(0, 12).forEach((svc) => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "arrival-card";
|
||||
|
||||
const title = document.createElement("h4");
|
||||
title.textContent = `Service ${svc.ServiceNo || "?"}`;
|
||||
|
||||
const line1 = document.createElement("p");
|
||||
line1.className = "arrival-line";
|
||||
line1.textContent = `Next: ${minutesUntil(svc.NextBus && svc.NextBus.EstimatedArrival)}`;
|
||||
|
||||
const line2 = document.createElement("p");
|
||||
line2.className = "arrival-line";
|
||||
line2.textContent = `Following: ${minutesUntil(
|
||||
svc.NextBus2 && svc.NextBus2.EstimatedArrival
|
||||
)}`;
|
||||
|
||||
card.appendChild(title);
|
||||
card.appendChild(line1);
|
||||
card.appendChild(line2);
|
||||
arrivalBoardEl.appendChild(card);
|
||||
});
|
||||
|
||||
arrivalBoardEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function callEndpoint(path, params) {
|
||||
const query = params.toString();
|
||||
const url = query ? `${path}?${query}` : path;
|
||||
|
||||
setStatus("Loading", "");
|
||||
responseEl.textContent = "Loading...";
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { raw: text };
|
||||
}
|
||||
|
||||
responseEl.textContent = JSON.stringify(payload, null, 2);
|
||||
if (response.ok) {
|
||||
setStatus(`OK ${response.status}`, "ok");
|
||||
} else {
|
||||
setStatus(`Error ${response.status}`, "error");
|
||||
}
|
||||
|
||||
renderArrivalBoard(payload);
|
||||
} catch (error) {
|
||||
responseEl.textContent = JSON.stringify(
|
||||
{
|
||||
error: "Request failed",
|
||||
message: error.message
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
setStatus("Network Error", "error");
|
||||
arrivalBoardEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function seedDefaultParams() {
|
||||
paramsEl.innerHTML = "";
|
||||
const selected = getSelectedEndpoint();
|
||||
|
||||
if (selected.path === "/api/v1/bus-arrival") {
|
||||
addParamRow("BusStopCode", "83139");
|
||||
addParamRow("ServiceNo", "");
|
||||
return;
|
||||
}
|
||||
|
||||
addParamRow("$skip", "0");
|
||||
addParamRow("$top", "10");
|
||||
}
|
||||
|
||||
function initEndpointOptions() {
|
||||
endpointEl.innerHTML = "";
|
||||
endpointOptions.forEach((item, index) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(index);
|
||||
opt.textContent = `${item.label} (${item.path})`;
|
||||
endpointEl.appendChild(opt);
|
||||
});
|
||||
|
||||
endpointEl.value = "0";
|
||||
methodEl.value = endpointOptions[0].method;
|
||||
seedDefaultParams();
|
||||
}
|
||||
|
||||
addParamBtn.addEventListener("click", () => addParamRow());
|
||||
|
||||
endpointEl.addEventListener("change", () => {
|
||||
const selected = getSelectedEndpoint();
|
||||
methodEl.value = selected.method;
|
||||
seedDefaultParams();
|
||||
});
|
||||
|
||||
sendRequestBtn.addEventListener("click", async () => {
|
||||
const selected = getSelectedEndpoint();
|
||||
const params = collectParams();
|
||||
await callEndpoint(selected.path, params);
|
||||
});
|
||||
|
||||
runArrivalBtn.addEventListener("click", async () => {
|
||||
const params = new URLSearchParams();
|
||||
const busStopCode = busStopCodeEl.value.trim();
|
||||
const serviceNo = serviceNoEl.value.trim();
|
||||
|
||||
if (!busStopCode) {
|
||||
responseEl.textContent = JSON.stringify(
|
||||
{ error: "BusStopCode is required for arrival checks." },
|
||||
null,
|
||||
2
|
||||
);
|
||||
setStatus("Missing BusStopCode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
params.append("BusStopCode", busStopCode);
|
||||
if (serviceNo) {
|
||||
params.append("ServiceNo", serviceNo);
|
||||
}
|
||||
|
||||
await callEndpoint("/api/v1/bus-arrival", params);
|
||||
});
|
||||
|
||||
async function init() {
|
||||
await loadOpenApiEndpoints();
|
||||
initEndpointOptions();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,88 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bus SG Control Deck</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Chakra+Petch:wght@600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-grid"></div>
|
||||
<main class="app-shell">
|
||||
<header class="hero">
|
||||
<p class="kicker">Singapore Transit</p>
|
||||
<h1>Bus Control Deck</h1>
|
||||
<p class="subtitle">
|
||||
Live explorer for the bus gateway API at <span id="gateway-origin">localhost:8067</span>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="panel query-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Endpoint Explorer</h2>
|
||||
<button id="send-request" class="btn-accent">Run Request</button>
|
||||
</div>
|
||||
|
||||
<div class="grid-two">
|
||||
<label>
|
||||
Endpoint
|
||||
<select id="endpoint"></select>
|
||||
</label>
|
||||
<label>
|
||||
Request Method
|
||||
<input id="method" value="GET" readonly />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="params-head">
|
||||
<h3>Query Params</h3>
|
||||
<button id="add-param" class="btn-ghost">Add Param</button>
|
||||
</div>
|
||||
<div id="params" class="params-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel quick-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Quick Arrival Check</h2>
|
||||
<button id="run-arrival" class="btn-accent">Get Arrivals</button>
|
||||
</div>
|
||||
<div class="grid-two">
|
||||
<label>
|
||||
Bus Stop Code
|
||||
<input id="bus-stop-code" placeholder="e.g. 83139" />
|
||||
</label>
|
||||
<label>
|
||||
Service No (optional)
|
||||
<input id="service-no" placeholder="e.g. 14" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">Uses /api/v1/bus-arrival with DataMall-style query parameters.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Response</h2>
|
||||
<span id="status-pill" class="status-pill">Idle</span>
|
||||
</div>
|
||||
<div id="arrival-board" class="arrival-board hidden"></div>
|
||||
<pre id="response" class="response">No request yet.</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<template id="param-template">
|
||||
<div class="param-row">
|
||||
<input class="param-key" placeholder="Key" />
|
||||
<input class="param-value" placeholder="Value" />
|
||||
<button class="remove-param" type="button">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,265 @@
|
||||
:root {
|
||||
--bg-0: #f7f4ef;
|
||||
--bg-1: #efe6d7;
|
||||
--ink: #1e1b16;
|
||||
--ink-soft: #5e5648;
|
||||
--accent: #d25f1f;
|
||||
--accent-dark: #923d0f;
|
||||
--line: #cbbca2;
|
||||
--ok: #1e7a3f;
|
||||
--card: rgba(255, 251, 243, 0.8);
|
||||
--shadow: 0 14px 30px rgba(54, 39, 12, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 15% 15%, #fff4dc 0%, transparent 35%),
|
||||
radial-gradient(circle at 82% 0%, #ffd3b6 0%, transparent 32%),
|
||||
linear-gradient(155deg, var(--bg-0), var(--bg-1));
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.2;
|
||||
background-image: linear-gradient(to right, #a88f63 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #a88f63 1px, transparent 1px);
|
||||
background-size: 36px 36px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1100px, calc(100% - 2rem));
|
||||
margin: 2rem auto 3rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 1rem;
|
||||
animation: rise 420ms ease-out both;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.17em;
|
||||
font-size: 0.76rem;
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: "Chakra Petch", sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 7vw, 3.6rem);
|
||||
line-height: 1;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 60ch;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: rise 520ms ease-out both;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.62rem 0.7rem;
|
||||
background: #fffcf5;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 2px solid #f5ac79;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, filter 140ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #d25f1f, #a94411);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: #f2e7d0;
|
||||
color: #3a3024;
|
||||
}
|
||||
|
||||
.params-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0.9rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.params-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.remove-param {
|
||||
background: #efe0d2;
|
||||
color: #552f19;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #efe8da;
|
||||
color: #5f5140;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
background: #d6f2df;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.status-pill.error {
|
||||
background: #f7dbd7;
|
||||
color: #8f2424;
|
||||
}
|
||||
|
||||
.arrival-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.arrival-card {
|
||||
border: 1px solid #d9c6a8;
|
||||
background: #fff8ea;
|
||||
border-radius: 14px;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.arrival-card h4 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.arrival-line {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: #4b4134;
|
||||
}
|
||||
|
||||
.response {
|
||||
margin: 0;
|
||||
background: #201d18;
|
||||
color: #f5e9cf;
|
||||
border-radius: 14px;
|
||||
padding: 0.8rem;
|
||||
min-height: 170px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
width: min(1100px, calc(100% - 1rem));
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.grid-two,
|
||||
.param-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const { URL } = require("url");
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const API_ORIGIN = process.env.API_ORIGIN || "http://localhost:8067";
|
||||
const PUBLIC_DIR = path.join(__dirname, "public");
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".ico": "image/x-icon"
|
||||
};
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body)
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function resolveFilePath(urlPathname) {
|
||||
const relative = urlPathname === "/" ? "/index.html" : urlPathname;
|
||||
const safePath = path.normalize(relative).replace(/^\.\.(\/|\\|$)/, "");
|
||||
return path.join(PUBLIC_DIR, safePath);
|
||||
}
|
||||
|
||||
function serveStatic(req, res, pathname) {
|
||||
const filePath = resolveFilePath(pathname);
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) {
|
||||
sendJson(res, 400, { error: "Invalid path" });
|
||||
return;
|
||||
}
|
||||
|
||||
fs.stat(filePath, (statErr, stats) => {
|
||||
if (statErr || !stats.isFile()) {
|
||||
sendJson(res, 404, { error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
function proxyApi(req, res, parsedUrl) {
|
||||
const targetUrl = new URL(parsedUrl.pathname + parsedUrl.search, API_ORIGIN);
|
||||
const isHttps = targetUrl.protocol === "https:";
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const options = {
|
||||
protocol: targetUrl.protocol,
|
||||
hostname: targetUrl.hostname,
|
||||
port: targetUrl.port || (isHttps ? 443 : 80),
|
||||
method: req.method,
|
||||
path: targetUrl.pathname + targetUrl.search,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: targetUrl.host
|
||||
}
|
||||
};
|
||||
|
||||
const proxyReq = transport.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 502, {
|
||||
...proxyRes.headers,
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
});
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on("error", (error) => {
|
||||
sendJson(res, 502, {
|
||||
error: "Gateway request failed",
|
||||
message: error.message
|
||||
});
|
||||
});
|
||||
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
proxyReq.end();
|
||||
return;
|
||||
}
|
||||
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const parsedUrl = new URL(req.url || "/", `http://${req.headers.host}`);
|
||||
|
||||
if (parsedUrl.pathname.startsWith("/api/")) {
|
||||
proxyApi(req, res, parsedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedUrl.pathname === "/openapi.json") {
|
||||
const targetUrl = new URL("/openapi.json", API_ORIGIN);
|
||||
const transport = targetUrl.protocol === "https:" ? https : http;
|
||||
const proxyReq = transport.request(targetUrl, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
proxyReq.on("error", () => sendJson(res, 502, { error: "Cannot fetch OpenAPI spec" }));
|
||||
proxyReq.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
serveStatic(req, res, parsedUrl.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 405, { error: "Method not allowed" });
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Bus SG app is running at http://localhost:${PORT}`);
|
||||
console.log(`Proxying API requests to ${API_ORIGIN}`);
|
||||
});
|
||||
Reference in New Issue
Block a user