From 151f942d9d8a641443654516da1d3948f1384382 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:33:50 +0000 Subject: [PATCH] chore(#5): switch to node runtime and update tooling/docs for persistence --- .gitignore | 1 + Dockerfile | 26 +-- README.md | 53 +++-- docker-compose.yml | 7 +- eslint.config.js | 13 +- package.json | 4 +- server.mjs | 554 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 8 files changed, 609 insertions(+), 52 deletions(-) create mode 100644 server.mjs diff --git a/.gitignore b/.gitignore index e2cbe7a..d448195 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ Thumbs.db # User-provided Docker data data/network.json +data/.backups # Vite vite.config.js.timestamp-* diff --git a/Dockerfile b/Dockerfile index 6dca253..3253d38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,24 +15,20 @@ COPY . . ENV DEPLOY_TARGET=docker RUN pnpm run build:docker -FROM nginxinc/nginx-unprivileged:1.27-alpine AS runner +FROM node:20-alpine AS runner -USER root +WORKDIR /app +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 -RUN apk add --no-cache libcap && \ - setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx +COPY --from=builder /app/build ./build +COPY --from=builder /app/server.mjs ./server.mjs +COPY --from=builder /app/data ./data -COPY nginx/default.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /app/build /usr/share/nginx/html - -RUN mkdir -p /usr/share/nginx/html/data && \ - chown -R 101:101 /usr/share/nginx/html /var/cache/nginx /var/run /etc/nginx/conf.d - -USER 101 - -EXPOSE 80 +EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD wget -qO- http://127.0.0.1/ > /dev/null || exit 1 + CMD wget -qO- http://127.0.0.1:3000/ > /dev/null || exit 1 -CMD ["nginx", "-g", "daemon off;"] +CMD ["node", "server.mjs"] diff --git a/README.md b/README.md index cac3cf6..a457d88 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/jcreek/OpenNetworkDiagram/docker.yml) ![Netlify](https://img.shields.io/netlify/3128f05f-831b-412c-ada0-46bc3d6e61d5) - - **A declarative, self-hosted tool for visualising and managing home lab & network architecture diagrams.** --- @@ -17,7 +15,8 @@ ✅ **Fully self-hostable via Docker** ✅ **Docker-first deployment target** (Netlify optional for demo hosting) ✅ **Interactive network visualisation** -✅ **Simple file-based configuration** +✅ **Single-page modal editor with live updates** +✅ **Debounced autosave to JSON (self-hosted)** ✅ **Lightweight Svelte** Use it to **document your home lab, office network, or cloud infrastructure** with an easy-to-use web interface. @@ -45,22 +44,26 @@ docker compose up --build Or pull and run directly: ```bash -docker run -d -p 8080:80 -v "$(pwd)/data:/usr/share/nginx/html/data:ro" jcreek23/open-network-diagram +docker run -d -p 8080:3000 \ + -e NETWORK_DATA_FILE=/app/data/network.json \ + -e NETWORK_BACKUP_DIR=/app/data/.backups \ + -v "$(pwd)/data:/app/data" \ + jcreek23/open-network-diagram ``` -- **`-p 8080:80`** → Maps the app to `http://localhost:8080` -- **`-v .../data:/usr/share/nginx/html/data:ro`** → Uses your local `data/network.json` +- **`-p 8080:3000`** → Maps the app to `http://localhost:8080` +- **`-v .../data:/app/data`** → Uses your local writable `data/network.json` ### **3️⃣ Open the Web UI** Visit **`http://localhost:8080`** to view your network diagram. -### **4️⃣ Modify Your Network (JSON-Based)** +### **4️⃣ Modify Your Network (Modal UI + JSON Persistence)** -- Docker runtime reads **`/data/network.json`** from the mounted volume. -- In this repo, your editable file is **`data/network.json`** (gitignored). -- Netlify demo builds use committed sample data at **`static/data/network.json`**. -- After editing JSON, click **Reload Default JSON** in the UI. +- Edit machines/devices/VMs/ports directly in the modal UI. +- Diagram updates immediately as you edit. +- In self-hosted Docker/local mode, changes autosave to mounted **`data/network.json`**. +- Netlify/demo is intentionally read-only; edits are in-memory only. --- @@ -92,7 +95,7 @@ pnpm run dev ```bash pnpm run build # default (Docker/static target) pnpm run build:docker # explicit Docker/static target -pnpm run build:netlify # explicit Netlify target +pnpm run build:netlify # Netlify target (read-only mode) ``` --- @@ -105,6 +108,7 @@ open-network-diagram/ ├── static/data/network.json # Demo dataset (Netlify/demo) ├── data/network.json.example # User data template (Docker) ├── Dockerfile # Docker build/runtime +├── server.mjs # Node runtime server (static + /api/network-data) ├── docker-compose.yml # Local Docker run with mounted data ├── netlify.toml # Netlify build config ├── .github/workflows/ # CI workflows (Docker image build/push) @@ -124,9 +128,22 @@ docker build -t open-network-diagram . ### **Run Locally** ```bash -docker run --rm -p 8080:80 -v "$(pwd)/data:/usr/share/nginx/html/data:ro" open-network-diagram +docker run --rm -p 8080:3000 \ + -e NETWORK_DATA_FILE=/app/data/network.json \ + -e NETWORK_BACKUP_DIR=/app/data/.backups \ + -v "$(pwd)/data:/app/data" \ + open-network-diagram ``` +### **Write API Environment Variables** + +- **`NETWORK_READ_ONLY`** (default: `false`) + Set to `true` to disable `PUT /api/network-data` and force read-only mode. +- **`NETWORK_DATA_FILE`** (default: `data/network.json`) + JSON file path to read/write. +- **`NETWORK_BACKUP_DIR`** (default: sibling `.backups`) + Backup directory for rolling save backups (last 5 retained). + ### **CI/CD (GitHub Actions + Netlify)** - GitHub Actions workflow (`.github/workflows/docker.yml`) builds Docker on PRs. @@ -163,16 +180,6 @@ Define your network using **`network.json`**: --- -## **🔜 Roadmap** - -✅ **Initial version with JSON-based diagrams** -⏳ **Drag-and-drop editing in the UI** -⏳ **Custom icons for different devices** -⏳ **Export diagrams as PNG/SVG/Graphviz** -⏳ **Dark mode & UI themes** - ---- - ## **🤝 Contributing** We welcome contributions! To contribute: diff --git a/docker-compose.yml b/docker-compose.yml index d4f567c..280ab4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,10 @@ services: dockerfile: Dockerfile image: open-network-diagram:local ports: - - "8080:80" + - "8080:3000" volumes: - - ./data:/usr/share/nginx/html/data:ro + - ./data:/app/data + environment: + NETWORK_DATA_FILE: "/app/data/network.json" + NETWORK_BACKUP_DIR: "/app/data/.backups" restart: unless-stopped diff --git a/eslint.config.js b/eslint.config.js index 3a02127..61e975f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -57,17 +57,10 @@ export default tseslint.config( 'no-inner-declarations': 'off', 'no-unused-vars': 'off', 'import/no-extraneous-dependencies': 'off', + 'import/no-unresolved': 'off', + 'import/prefer-default-export': 'off', '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - mjs: 'never', - cjs: 'never', - ts: 'never' - } - ] + 'import/extensions': 'off' } }, { diff --git a/package.json b/package.json index ec7bcf1..e744f6e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite dev", "build": "vite build", "build:docker": "DEPLOY_TARGET=docker vite build", - "build:netlify": "DEPLOY_TARGET=netlify vite build", + "build:netlify": "NETWORK_READ_ONLY=true DEPLOY_TARGET=netlify vite build", + "start": "node server.mjs", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -26,6 +27,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.13.10", "@types/cytoscape-dagre": "^2.3.3", "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..1e394e5 --- /dev/null +++ b/server.mjs @@ -0,0 +1,554 @@ +import { constants } from 'node:fs'; +import { + access, + copyFile, + mkdir, + open, + readFile, + readdir, + rename, + stat, + unlink +} from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentFilePath = fileURLToPath(import.meta.url); +const currentDirPath = path.dirname(currentFilePath); +const buildDir = path.resolve(currentDirPath, 'build'); + +const HOST = process.env.HOST ?? '0.0.0.0'; +const PORT = Number(process.env.PORT ?? 3000); + +const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true'; +const NETWORK_DATA_FILE = path.resolve( + process.env.NETWORK_DATA_FILE ?? path.join(currentDirPath, 'data/network.json') +); +const NETWORK_BACKUP_DIR = path.resolve( + process.env.NETWORK_BACKUP_DIR ?? path.join(path.dirname(NETWORK_DATA_FILE), '.backups') +); + +function isWriteEnabled() { + return !NETWORK_READ_ONLY; +} + +const contentTypeByExtension = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml; charset=utf-8', + '.ico': 'image/x-icon', + '.webp': 'image/webp', + '.txt': 'text/plain; charset=utf-8' +}; + +function sendJson(response, status, payload) { + const body = JSON.stringify(payload); + response.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store' + }); + response.end(body); +} + +function badRequest(response, message, details) { + sendJson(response, 400, { + error: message, + ...(details ? { details } : {}) + }); +} + +function normalizePort(port) { + if (!port || typeof port !== 'object') { + return null; + } + if (typeof port.portName !== 'string' || port.portName.trim() === '') { + return null; + } + const normalized = { + portName: port.portName.trim() + }; + if (typeof port.speedGbps === 'number' && !Number.isNaN(port.speedGbps)) { + normalized.speedGbps = port.speedGbps; + } + if (port.connectedTo && typeof port.connectedTo === 'object') { + if ( + typeof port.connectedTo.device === 'string' && + port.connectedTo.device.trim() !== '' && + typeof port.connectedTo.port === 'string' && + port.connectedTo.port.trim() !== '' + ) { + normalized.connectedTo = { + device: port.connectedTo.device.trim(), + port: port.connectedTo.port.trim() + }; + } + } + return normalized; +} + +function normalizeVm(vm) { + if (!vm || typeof vm !== 'object') { + return null; + } + if ( + typeof vm.name !== 'string' || + vm.name.trim() === '' || + typeof vm.role !== 'string' || + vm.role.trim() === '' || + typeof vm.ipAddress !== 'string' || + vm.ipAddress.trim() === '' + ) { + return null; + } + return { + name: vm.name.trim(), + role: vm.role.trim(), + ipAddress: vm.ipAddress.trim(), + ...(typeof vm.iconKey === 'string' && vm.iconKey.trim() ? { iconKey: vm.iconKey.trim() } : {}) + }; +} + +function validateNetworkData(value) { + const errors = []; + if (!value || typeof value !== 'object') { + return { + valid: false, + errors: [{ path: '$', message: 'must be an object with machines and devices arrays' }] + }; + } + if (!Array.isArray(value.machines)) { + errors.push({ path: '$.machines', message: 'must be an array' }); + } + if (!Array.isArray(value.devices)) { + errors.push({ path: '$.devices', message: 'must be an array' }); + } + + const machines = []; + for (const [machineIndex, sourceMachine] of (Array.isArray(value.machines) + ? value.machines + : [] + ).entries()) { + if (!sourceMachine || typeof sourceMachine !== 'object') { + errors.push({ path: `$.machines[${machineIndex}]`, message: 'must be an object' }); + continue; + } + + const requiredMachineFields = ['machineName', 'ipAddress', 'role', 'operatingSystem']; + let invalidField = false; + for (const fieldName of requiredMachineFields) { + if (typeof sourceMachine[fieldName] !== 'string' || sourceMachine[fieldName].trim() === '') { + errors.push({ path: `$.machines[${machineIndex}].${fieldName}`, message: 'is required' }); + invalidField = true; + } + } + const { hardware } = sourceMachine; + if (!hardware || typeof hardware !== 'object') { + errors.push({ path: `$.machines[${machineIndex}].hardware`, message: 'must be an object' }); + invalidField = true; + } + const { software } = sourceMachine; + if (!software || typeof software !== 'object' || !Array.isArray(software.vms)) { + errors.push({ + path: `$.machines[${machineIndex}].software.vms`, + message: 'must be an array' + }); + invalidField = true; + } + if (invalidField) { + continue; + } + + const vmNames = new Set(); + const vms = []; + for (const [vmIndex, sourceVm] of software.vms.entries()) { + const vm = normalizeVm(sourceVm); + if (!vm) { + errors.push({ + path: `$.machines[${machineIndex}].software.vms[${vmIndex}]`, + message: 'invalid vm' + }); + continue; + } + const vmKey = vm.name.toLowerCase(); + if (vmNames.has(vmKey)) { + errors.push({ + path: `$.machines[${machineIndex}].software.vms[${vmIndex}].name`, + message: `duplicate vm name "${vm.name}"` + }); + } + vmNames.add(vmKey); + vms.push(vm); + } + + const ports = []; + const portNames = new Set(); + for (const [portIndex, sourcePort] of (Array.isArray(sourceMachine.ports) + ? sourceMachine.ports + : [] + ).entries()) { + const port = normalizePort(sourcePort); + if (!port) { + errors.push({ + path: `$.machines[${machineIndex}].ports[${portIndex}]`, + message: 'invalid port' + }); + continue; + } + const portKey = port.portName.toLowerCase(); + if (portNames.has(portKey)) { + errors.push({ + path: `$.machines[${machineIndex}].ports[${portIndex}].portName`, + message: `duplicate port name "${port.portName}"` + }); + } + portNames.add(portKey); + ports.push(port); + } + + machines.push({ + machineName: sourceMachine.machineName.trim(), + ipAddress: sourceMachine.ipAddress.trim(), + role: sourceMachine.role.trim(), + operatingSystem: sourceMachine.operatingSystem.trim(), + ...(typeof sourceMachine.iconKey === 'string' && sourceMachine.iconKey.trim() + ? { iconKey: sourceMachine.iconKey.trim() } + : {}), + software: { + vms + }, + hardware: { + cpu: String(hardware.cpu ?? ''), + ram: String(hardware.ram ?? ''), + networkPorts: Number(hardware.networkPorts ?? 0), + ...(typeof hardware.networkPortSpeedGbps === 'number' + ? { networkPortSpeedGbps: hardware.networkPortSpeedGbps } + : {}), + ...(typeof hardware.gpu === 'string' && hardware.gpu.trim() + ? { gpu: hardware.gpu.trim() } + : {}) + }, + ports + }); + } + + const devices = []; + for (const [deviceIndex, sourceDevice] of (Array.isArray(value.devices) + ? value.devices + : [] + ).entries()) { + if (!sourceDevice || typeof sourceDevice !== 'object') { + errors.push({ path: `$.devices[${deviceIndex}]`, message: 'must be an object' }); + continue; + } + const requiredDeviceFields = ['name', 'ipAddress', 'type']; + let invalidField = false; + for (const fieldName of requiredDeviceFields) { + if (typeof sourceDevice[fieldName] !== 'string' || sourceDevice[fieldName].trim() === '') { + errors.push({ path: `$.devices[${deviceIndex}].${fieldName}`, message: 'is required' }); + invalidField = true; + } + } + if (invalidField) { + continue; + } + + const ports = []; + const portNames = new Set(); + for (const [portIndex, sourcePort] of (Array.isArray(sourceDevice.ports) + ? sourceDevice.ports + : [] + ).entries()) { + const port = normalizePort(sourcePort); + if (!port) { + errors.push({ + path: `$.devices[${deviceIndex}].ports[${portIndex}]`, + message: 'invalid port' + }); + continue; + } + const portKey = port.portName.toLowerCase(); + if (portNames.has(portKey)) { + errors.push({ + path: `$.devices[${deviceIndex}].ports[${portIndex}].portName`, + message: `duplicate port name "${port.portName}"` + }); + } + portNames.add(portKey); + ports.push(port); + } + + devices.push({ + name: sourceDevice.name.trim(), + ipAddress: sourceDevice.ipAddress.trim(), + type: sourceDevice.type.trim(), + ...(typeof sourceDevice.iconKey === 'string' && sourceDevice.iconKey.trim() + ? { iconKey: sourceDevice.iconKey.trim() } + : {}), + ...(typeof sourceDevice.notes === 'string' ? { notes: sourceDevice.notes } : {}), + ports + }); + } + + const machineNames = new Set(); + for (const [index, machine] of machines.entries()) { + const key = machine.machineName.toLowerCase(); + if (machineNames.has(key)) { + errors.push({ + path: `$.machines[${index}].machineName`, + message: `duplicate machine name "${machine.machineName}"` + }); + } + machineNames.add(key); + } + + const deviceNames = new Set(); + for (const [index, device] of devices.entries()) { + const key = device.name.toLowerCase(); + if (deviceNames.has(key)) { + errors.push({ + path: `$.devices[${index}].name`, + message: `duplicate device name "${device.name}"` + }); + } + deviceNames.add(key); + } + + for (const [index, machine] of machines.entries()) { + if (deviceNames.has(machine.machineName.toLowerCase())) { + errors.push({ + path: `$.machines[${index}].machineName`, + message: `name "${machine.machineName}" collides with a device name` + }); + } + } + + return { + valid: errors.length === 0, + errors, + data: { + machines, + devices + } + }; +} + +async function createBackupIfPresent(filePath) { + try { + await access(filePath, constants.F_OK); + } catch { + return; + } + await mkdir(NETWORK_BACKUP_DIR, { recursive: true }); + const baseName = path.basename(filePath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = path.join(NETWORK_BACKUP_DIR, `${baseName}.${timestamp}.bak`); + await copyFile(filePath, backupPath); +} + +async function trimBackups(filePath, keep = 5) { + let files = []; + try { + files = await readdir(NETWORK_BACKUP_DIR); + } catch { + return; + } + const baseName = path.basename(filePath); + const matches = await Promise.all( + files + .filter((entry) => entry.startsWith(`${baseName}.`) && entry.endsWith('.bak')) + .map(async (entry) => { + const entryPath = path.join(NETWORK_BACKUP_DIR, entry); + const info = await stat(entryPath); + return { path: entryPath, mtime: info.mtimeMs }; + }) + ); + matches.sort((a, b) => b.mtime - a.mtime); + await Promise.all(matches.slice(keep).map((entry) => unlink(entry.path).catch(() => undefined))); +} + +async function writeNetworkFile(payload) { + const directory = path.dirname(NETWORK_DATA_FILE); + await mkdir(directory, { recursive: true }); + await createBackupIfPresent(NETWORK_DATA_FILE); + + const tempPath = `${NETWORK_DATA_FILE}.tmp-${Date.now()}-${process.pid}`; + const fileHandle = await open(tempPath, 'w'); + try { + await fileHandle.writeFile(`${JSON.stringify(payload, null, '\t')}\n`, 'utf8'); + await fileHandle.sync(); + } finally { + await fileHandle.close(); + } + await rename(tempPath, NETWORK_DATA_FILE); + await trimBackups(NETWORK_DATA_FILE, 5); +} + +async function readNetworkFile() { + const raw = await readFile(NETWORK_DATA_FILE, 'utf8'); + const parsed = JSON.parse(raw); + const fileStats = await stat(NETWORK_DATA_FILE); + return { + data: parsed, + source: NETWORK_DATA_FILE, + updatedAt: fileStats.mtime.toISOString() + }; +} + +async function serveApi(request, response) { + if (request.method === 'GET') { + try { + const payload = await readNetworkFile(); + const validation = validateNetworkData(payload.data); + if (!validation.valid) { + sendJson(response, 500, { + error: 'Stored network data is invalid', + details: validation.errors + }); + return; + } + + let canWrite = false; + if (isWriteEnabled()) { + try { + await mkdir(path.dirname(NETWORK_DATA_FILE), { recursive: true }); + await access(path.dirname(NETWORK_DATA_FILE), constants.W_OK); + canWrite = true; + } catch { + canWrite = false; + } + } + + sendJson(response, 200, { + data: validation.data, + writable: canWrite, + source: payload.source, + updatedAt: payload.updatedAt + }); + } catch (error) { + sendJson(response, 500, { + error: error instanceof Error ? error.message : 'Failed to read network data' + }); + } + return; + } + + if (request.method === 'PUT') { + if (!isWriteEnabled()) { + sendJson(response, 403, { + error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.' + }); + return; + } + + let raw = ''; + for await (const chunk of request) { + raw += chunk; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + badRequest(response, 'Request body must be valid JSON'); + return; + } + + const validation = validateNetworkData(parsed); + if (!validation.valid) { + badRequest(response, 'Network data validation failed', validation.errors); + return; + } + + try { + await writeNetworkFile(validation.data); + const fileStats = await stat(NETWORK_DATA_FILE); + sendJson(response, 200, { + data: validation.data, + writable: true, + source: NETWORK_DATA_FILE, + updatedAt: fileStats.mtime.toISOString() + }); + } catch (error) { + sendJson(response, 500, { + error: error instanceof Error ? error.message : 'Failed to persist network data' + }); + } + return; + } + + sendJson(response, 405, { error: 'Method not allowed' }); +} + +function safeRelativePath(requestPath) { + const decoded = decodeURIComponent(requestPath); + const normalized = path.posix.normalize(decoded); + if (normalized.startsWith('..')) { + return null; + } + return normalized; +} + +async function serveStatic(requestPath, response) { + const relativePath = safeRelativePath(requestPath); + if (relativePath === null) { + response.writeHead(400); + response.end('Bad request'); + return; + } + + const requestedFilePath = path.join( + buildDir, + relativePath === '/' ? 'index.html' : relativePath.slice(1) + ); + + const sendFile = async (filePath) => { + const ext = path.extname(filePath).toLowerCase(); + const contentType = contentTypeByExtension[ext] ?? 'application/octet-stream'; + const content = await readFile(filePath); + response.writeHead(200, { + 'Content-Type': contentType, + 'Cache-Control': ext === '.html' ? 'no-store' : 'public, max-age=31536000, immutable' + }); + response.end(content); + }; + + try { + const fileInfo = await stat(requestedFilePath); + if (fileInfo.isDirectory()) { + await sendFile(path.join(requestedFilePath, 'index.html')); + return; + } + await sendFile(requestedFilePath); + return; + } catch { + // continue with SPA fallback + } + + try { + await sendFile(path.join(buildDir, 'index.html')); + } catch { + response.writeHead(404); + response.end('Not found'); + } +} + +const server = createServer(async (request, response) => { + const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`); + if (requestUrl.pathname === '/api/network-data') { + await serveApi(request, response); + return; + } + await serveStatic(requestUrl.pathname, response); +}); + +server.listen(PORT, HOST, () => { + // eslint-disable-next-line no-console + console.log(`[OpenNetworkDiagram] listening on http://${HOST}:${PORT}`); +}); diff --git a/tsconfig.json b/tsconfig.json index 0b2d886..891eb95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["node"] } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files