Add Docker WASM VICE program runner
This commit is contained in:
parent
d92ea59d85
commit
57d30e38d4
10 changed files with 297 additions and 77 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.git
|
||||||
|
.env.example
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
4
.env
Normal file
4
.env
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
HOST_PORT=8134
|
||||||
|
PROGRAM_REPOSITORY=https://git.p20.dev/christian/c64-basic
|
||||||
|
PROGRAM_BRANCH=main
|
||||||
|
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.mjs ./server.mjs
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
USER node
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
|
|
||||||
42
README.md
42
README.md
|
|
@ -7,26 +7,36 @@ machine-language payload is required.
|
||||||
|
|
||||||
## Programs
|
## Programs
|
||||||
|
|
||||||
| Program | Type | Controls | Description | Play online |
|
| Program | Type | Controls | Description | Run locally |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#meteor-dodge) |
|
| [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](http://localhost:8134/?program=meteor-dodge%2Fmeteor-dodge.prg) |
|
||||||
| [Code Breaker](code-breaker/code-breaker.bas) | Logic | Digits, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#code-breaker) |
|
| [Code Breaker](code-breaker/code-breaker.bas) | Logic | Digits, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](http://localhost:8134/?program=code-breaker%2Fcode-breaker.prg) |
|
||||||
| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Coordinates, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#submarine-hunt) |
|
| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Coordinates, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](http://localhost:8134/?program=submarine-hunt%2Fsubmarine-hunt.prg) |
|
||||||
| [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#lunar-lander) |
|
| [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](http://localhost:8134/?program=lunar-lander%2Flunar-lander.prg) |
|
||||||
| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`-`4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#memory-matrix) |
|
| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`-`4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](http://localhost:8134/?program=memory-matrix%2Fmemory-matrix.prg) |
|
||||||
| [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#robot-maze) |
|
| [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](http://localhost:8134/?program=robot-maze%2Frobot-maze.prg) |
|
||||||
| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`-`3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#music-box) |
|
| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`-`3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](http://localhost:8134/?program=music-box%2Fmusic-box.prg) |
|
||||||
| [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#pixel-painter) |
|
| [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](http://localhost:8134/?program=pixel-painter%2Fpixel-painter.prg) |
|
||||||
| [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#stock-trader) |
|
| [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](http://localhost:8134/?program=stock-trader%2Fstock-trader.prg) |
|
||||||
| [Starfield](starfield/starfield.bas) | Demo | `1`-`3`, `Q` | Watch and control a layered animated starfield. | [Run program](https://htmlpreview.github.io/?https://git.p20.dev/christian/c64-basic/raw/branch/main/runner.html#starfield) |
|
| [Starfield](starfield/starfield.bas) | Demo | `1`-`3`, `Q` | Watch and control a layered animated starfield. | [Run program](http://localhost:8134/?program=starfield%2Fstarfield.prg) |
|
||||||
|
|
||||||
## Running the programs
|
## Running the programs
|
||||||
|
|
||||||
Click a **Run program** link in the table to launch that program directly in a
|
Start the local browser app with Docker Compose:
|
||||||
browser-based VICE x64sc emulator powered by WebAssembly. The runner downloads
|
|
||||||
the emulator from the EmulatorJS CDN; the program itself is embedded in the
|
```text
|
||||||
repository's runner, so no file picker or manual loading is required. Click the
|
docker compose up --build -d
|
||||||
emulator once to give it keyboard focus and enable audio.
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:8134](http://localhost:8134), or click a **Run program**
|
||||||
|
link in the table. The app runs the VICE x64sc core through WebAssembly and
|
||||||
|
downloads the selected PRG through its own backend, avoiding browser CORS
|
||||||
|
restrictions. Click inside the emulator to enable keyboard input and sound.
|
||||||
|
|
||||||
|
The app reads the public repository tree at runtime. New `.prg` files pushed to
|
||||||
|
the configured branch appear after clicking **Refresh list**; rebuilding the
|
||||||
|
Docker image is not required. Repository, branch, and host port settings live in
|
||||||
|
`.env`, where the default host port is `8134`.
|
||||||
|
|
||||||
Each directory contains an editable, line-numbered `.bas` listing and a matching
|
Each directory contains an editable, line-numbered `.bas` listing and a matching
|
||||||
tokenized `.prg` file. The PRGs use the normal C64 BASIC load address, `$0801`.
|
tokenized `.prg` file. The PRGs use the normal C64 BASIC load address, `$0801`.
|
||||||
|
|
|
||||||
10
compose.yaml
Normal file
10
compose.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
services:
|
||||||
|
c64-basic:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-8134}:8080"
|
||||||
|
environment:
|
||||||
|
PROGRAM_REPOSITORY: "${PROGRAM_REPOSITORY:-https://git.p20.dev/christian/c64-basic}"
|
||||||
|
PROGRAM_BRANCH: "${PROGRAM_BRANCH:-main}"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
71
public/app.js
Normal file
71
public/app.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
const library = document.querySelector('#library');
|
||||||
|
const player = document.querySelector('#player');
|
||||||
|
const status = document.querySelector('#status');
|
||||||
|
const programsElement = document.querySelector('#programs');
|
||||||
|
const refreshButton = document.querySelector('#refresh');
|
||||||
|
|
||||||
|
function titleFromPath(path) {
|
||||||
|
const filename = path.split('/').at(-1).replace(/\.prg$/i, '');
|
||||||
|
return filename.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrograms() {
|
||||||
|
status.hidden = false;
|
||||||
|
status.className = 'status';
|
||||||
|
status.textContent = 'Loading programs…';
|
||||||
|
programsElement.replaceChildren();
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/programs', { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error('The program list could not be loaded.');
|
||||||
|
const { programs } = await response.json();
|
||||||
|
for (const program of programs) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.className = 'program-card';
|
||||||
|
link.href = `/?program=${encodeURIComponent(program.path)}`;
|
||||||
|
const title = document.createElement('strong');
|
||||||
|
title.textContent = titleFromPath(program.path);
|
||||||
|
const action = document.createElement('span');
|
||||||
|
action.textContent = 'Run program →';
|
||||||
|
link.append(title, action);
|
||||||
|
programsElement.append(link);
|
||||||
|
}
|
||||||
|
status.textContent = programs.length ? `${programs.length} programs available` : 'No PRG files found.';
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'status error';
|
||||||
|
status.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEmulator(programPath) {
|
||||||
|
library.hidden = true;
|
||||||
|
player.hidden = false;
|
||||||
|
const title = titleFromPath(programPath);
|
||||||
|
document.title = `${title} — C64 BASIC Collection`;
|
||||||
|
document.querySelector('#program-title').textContent = title;
|
||||||
|
|
||||||
|
window.EJS_player = '#game';
|
||||||
|
window.EJS_core = 'vice_x64sc';
|
||||||
|
window.EJS_gameUrl = `/api/program?path=${encodeURIComponent(programPath)}`;
|
||||||
|
window.EJS_gameName = programPath.split('/').at(-1);
|
||||||
|
window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/';
|
||||||
|
window.EJS_startOnLoaded = true;
|
||||||
|
window.EJS_language = 'en-US';
|
||||||
|
|
||||||
|
const loader = document.createElement('script');
|
||||||
|
loader.src = `${window.EJS_pathtodata}loader.js`;
|
||||||
|
loader.onerror = () => {
|
||||||
|
const message = document.querySelector('#emulator-error');
|
||||||
|
message.hidden = false;
|
||||||
|
message.textContent = 'The WASM VICE emulator could not be downloaded. Check your connection and try again.';
|
||||||
|
};
|
||||||
|
document.body.append(loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshButton.addEventListener('click', loadPrograms);
|
||||||
|
const programPath = new URLSearchParams(location.search).get('program');
|
||||||
|
if (programPath) startEmulator(programPath);
|
||||||
|
else loadPrograms();
|
||||||
|
|
||||||
40
public/index.html
Normal file
40
public/index.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Run the C64 BASIC Collection in a browser-based WASM VICE emulator.">
|
||||||
|
<title>C64 BASIC Collection</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/">C64 BASIC Collection</a>
|
||||||
|
<span>VICE x64sc · WebAssembly</span>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section id="library" class="panel">
|
||||||
|
<p class="eyebrow">Program library</p>
|
||||||
|
<h1>Choose a program</h1>
|
||||||
|
<p class="lede">Programs are read directly from the public repository each time this page loads.</p>
|
||||||
|
<p id="status" class="status">Loading programs…</p>
|
||||||
|
<div id="programs" class="program-grid" aria-live="polite"></div>
|
||||||
|
<button id="refresh" class="secondary" type="button">Refresh list</button>
|
||||||
|
</section>
|
||||||
|
<section id="player" class="player" hidden>
|
||||||
|
<div class="player-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Now running</p>
|
||||||
|
<h1 id="program-title"></h1>
|
||||||
|
</div>
|
||||||
|
<a class="secondary button" href="/">Back to library</a>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Click inside the emulator to enable keyboard input and sound.</p>
|
||||||
|
<div id="game"></div>
|
||||||
|
<p id="emulator-error" class="error" hidden></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
32
public/styles.css
Normal file
32
public/styles.css
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
background: #121024;
|
||||||
|
color: #f7f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #33265d, #121024 42rem); }
|
||||||
|
.site-header { display: flex; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 5vw, 4rem); border-bottom: 1px solid #ffffff1c; color: #bdb5dd; }
|
||||||
|
.brand { color: #fff; font-weight: 800; text-decoration: none; }
|
||||||
|
main { width: min(1100px, calc(100% - 2rem)); margin: 0 auto; padding: clamp(2rem, 6vw, 5rem) 0; }
|
||||||
|
.panel { max-width: 900px; margin: auto; }
|
||||||
|
.eyebrow { margin: 0 0 .5rem; color: #c9a7ff; font-size: .75rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; }
|
||||||
|
.lede, .hint { color: #c8c1df; }
|
||||||
|
.status { margin: 2rem 0 1rem; color: #9ce6c2; }
|
||||||
|
.program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; }
|
||||||
|
.program-card { display: flex; min-height: 125px; flex-direction: column; justify-content: space-between; padding: 1.25rem; border: 1px solid #ffffff24; border-radius: 14px; background: #211c3bcc; color: #fff; text-decoration: none; transition: transform .15s, border-color .15s; }
|
||||||
|
.program-card:hover { transform: translateY(-3px); border-color: #b58cff; }
|
||||||
|
.program-card span { color: #bca8f1; font-size: .9rem; }
|
||||||
|
.button, button { display: inline-block; margin-top: 1.5rem; padding: .7rem 1rem; border: 1px solid #ffffff2b; border-radius: 8px; background: #302653; color: #fff; font: inherit; text-decoration: none; cursor: pointer; }
|
||||||
|
button:disabled { opacity: .5; cursor: wait; }
|
||||||
|
.player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; }
|
||||||
|
.player-heading h1 { font-size: clamp(1.8rem, 5vw, 3rem); }
|
||||||
|
#game { width: min(100%, 960px); height: min(75vw, 720px); min-height: 480px; margin: 1.5rem auto 0; background: #000; }
|
||||||
|
.error { color: #ffb4ab; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.site-header span { display: none; }
|
||||||
|
.player-heading { align-items: start; flex-direction: column; }
|
||||||
|
#game { min-height: 60vh; }
|
||||||
|
}
|
||||||
61
runner.html
61
runner.html
File diff suppressed because one or more lines are too long
97
server.mjs
Normal file
97
server.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { extname, join, normalize } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT || 8080);
|
||||||
|
const repository = (process.env.PROGRAM_REPOSITORY || 'https://git.p20.dev/christian/c64-basic').replace(/\/$/, '');
|
||||||
|
const branch = process.env.PROGRAM_BRANCH || 'main';
|
||||||
|
const publicDirectory = fileURLToPath(new URL('./public/', import.meta.url));
|
||||||
|
const repositoryMatch = repository.match(/^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
|
||||||
|
|
||||||
|
if (!repositoryMatch) {
|
||||||
|
throw new Error('PROGRAM_REPOSITORY must look like https://host/owner/repository');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, repositoryHost, repositoryOwner, repositoryName] = repositoryMatch;
|
||||||
|
const apiBase = `https://${repositoryHost}/api/v1/repos/${encodeURIComponent(repositoryOwner)}/${encodeURIComponent(repositoryName)}`;
|
||||||
|
|
||||||
|
function sendJson(response, status, value) {
|
||||||
|
const body = JSON.stringify(value);
|
||||||
|
response.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'Cache-Control': 'no-store'
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPrograms() {
|
||||||
|
const treeUrl = `${apiBase}/git/trees/${encodeURIComponent(branch)}?recursive=true`;
|
||||||
|
const upstream = await fetch(treeUrl, { signal: AbortSignal.timeout(10000) });
|
||||||
|
if (!upstream.ok) throw new Error(`Repository returned HTTP ${upstream.status}`);
|
||||||
|
const tree = await upstream.json();
|
||||||
|
return tree.tree
|
||||||
|
.filter(entry => entry.type === 'blob' && entry.path.toLowerCase().endsWith('.prg'))
|
||||||
|
.map(entry => ({ path: entry.path, size: entry.size }))
|
||||||
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyProgram(response, programPath) {
|
||||||
|
const programs = await fetchPrograms();
|
||||||
|
if (!programs.some(program => program.path === programPath)) {
|
||||||
|
sendJson(response, 404, { error: 'Program not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedPath = programPath.split('/').map(encodeURIComponent).join('/');
|
||||||
|
const rawUrl = `${repository}/raw/branch/${encodeURIComponent(branch)}/${encodedPath}`;
|
||||||
|
const upstream = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) });
|
||||||
|
if (!upstream.ok) throw new Error(`Program download returned HTTP ${upstream.status}`);
|
||||||
|
const bytes = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': bytes.length,
|
||||||
|
'Content-Disposition': `inline; filename="${programPath.split('/').at(-1)}"`,
|
||||||
|
'Cache-Control': 'no-store'
|
||||||
|
});
|
||||||
|
response.end(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(response, pathname) {
|
||||||
|
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||||
|
const filePath = normalize(join(publicDirectory, requested));
|
||||||
|
if (!filePath.startsWith(publicDirectory) || !existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': types[extname(filePath)] || 'application/octet-stream',
|
||||||
|
'Content-Length': statSync(filePath).size,
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
});
|
||||||
|
createReadStream(filePath).pipe(response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
sendJson(response, 405, { error: 'Method not allowed' });
|
||||||
|
} else if (url.pathname === '/api/programs') {
|
||||||
|
sendJson(response, 200, { repository, branch, programs: await fetchPrograms() });
|
||||||
|
} else if (url.pathname === '/api/program') {
|
||||||
|
await proxyProgram(response, url.searchParams.get('path') || '');
|
||||||
|
} else if (!serveStatic(response, url.pathname)) {
|
||||||
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (!response.headersSent) sendJson(response, 502, { error: 'Unable to reach the program repository' });
|
||||||
|
else response.end();
|
||||||
|
}
|
||||||
|
}).listen(port, '0.0.0.0', () => {
|
||||||
|
console.log(`C64 BASIC runner listening on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue