websocket works

This commit is contained in:
Chris Wanstrath 2025-09-20 13:12:28 -07:00
parent 15a225e68f
commit 227029c8f6
13 changed files with 140 additions and 26 deletions

View File

@ -5,3 +5,9 @@
- [ ] Runs one-shot TypeScript commands (via NOSE terminal)
- [x] Has a 960x540 (16:9) virtual screen size that scales to the actual size of the display
- [x] Runs on a Raspberry Pi 5
## Fonts
Use this to examine what's inside the C64 .woff2 font file in public/vendor:
https://wakamaifondue.com/

View File

@ -23,7 +23,7 @@ export const Terminal: FC = async () => (
/>
</div>
<ul id="command-history">
<ul id="scrollback">
<li class="center">**** NOSE PLUTO V{new Date().getMonth() + 1}.{new Date().getDate()} ****</li>
<li class="center">VRAM <span id="vram-size">000KB</span></li>
</ul>

View File

@ -88,9 +88,11 @@ main {
height: 540px;
background: var(--c64-dark-blue);
color: var(--c64-light-blue);
/* nearest-neighbor scaling */
image-rendering: pixelated;
transform-origin: center center;
}
body[data-mode=tall] #content {

View File

@ -4,6 +4,7 @@
--cli-margin: 10px 0 0 25px;
--cli-font-size: 20px;
--cli-height: 30px;
--cli-status-width: 8px;
}
#command-line {
@ -71,17 +72,23 @@
border: none;
}
#command-history {
/* The scrollback shows your previous inputs and outputs. */
#scrollback {
margin: 0;
padding: 0;
font-size: var(--cli-font-size);
}
#command-history li {
#scrollback li {
list-style: none;
margin: 0;
}
.center {
#scrollback .center {
text-align: center;
}
#scrollback .input .content {
margin-left: var(--cli-status-width);
}

View File

@ -1,17 +1,32 @@
////
// DOM helpers and cached elements
// finds an element by ID
export const $ = (id: string): HTMLElement | null =>
document.getElementById(id)
// elements we know will be there... right?
export const cmdLine = $("command-line") as HTMLDivElement
export const cmdTextbox = $("command-textbox") as HTMLTextAreaElement
export const scrollback = $("scrollback") as HTMLUListElement
// creates an HTML element
export const $$ = (tag: string, innerHTML = ""): HTMLElement => {
const el = document.createElement(tag)
if (innerHTML) el.innerHTML = innerHTML
return el
// finds an element by ID
export function $(id: string): HTMLElement | null {
return document.getElementById(id)
}
// elements we know will be there... right?
export const cmdLine = document.getElementById("command-line") as HTMLDivElement
export const cmdTextbox = document.getElementById("command-textbox") as HTMLTextAreaElement
// creates an HTML element, optinally with one or more classes
// shortcut:
// $$("span.input.green") = <span class="input green"></span>
// $$(".input.green") = <div class="input green"></div>
export const $$ = (tag: string, innerHTML = ""): HTMLElement => {
let name: string | undefined = tag
let classList: string[] = []
if (tag.startsWith("."))
tag = "div" + tag
if (tag.includes("."))
[name, ...classList] = tag.split(".").map(x => x.trim())
const el = document.createElement(name!)
if (innerHTML) el.innerHTML = innerHTML
if (classList.length) el.classList.add(...classList)
return el
}

View File

@ -18,11 +18,14 @@ function inputHandler(event: KeyboardEvent) {
cmdTextbox.rows += 1
cmdLine.dataset.extended = "true"
} else if (event.key === "Enter") {
cmdTextbox.value = ""
cmdTextbox.rows = 1
cmdLine.dataset.extended = "false"
event.preventDefault()
runCommand(cmdTextbox.value)
clearInput()
}
}
function clearInput() {
cmdTextbox.value = ""
cmdTextbox.rows = 1
cmdLine.dataset.extended = "false"
}

48
src/js/scrollback.ts Normal file
View File

@ -0,0 +1,48 @@
////
// The scrollback shows your history of interacting with the shell.
// input, output, etc
import { scrollback, $$ } from "./dom.js"
type InputStatus = "waiting" | "streaming" | "ok" | "error"
export function addInput(id: string, input: string) {
const parent = $$("li.input")
const status = $$("span.status.yellow", "•")
const content = $$("span.content", input)
parent.append(status, content)
parent.dataset.id = id
scrollback.append(parent)
}
export function setStatus(id: string, status: InputStatus) {
const statusEl = document.querySelector(`[data-id="${id}"].input .status`)
if (!statusEl) return
statusEl.className = ""
switch (status) {
case "waiting":
statusEl.classList.add("yellow")
break
case "streaming":
statusEl.classList.add("purple")
break
case "ok":
statusEl.classList.add("green")
break
case "error":
statusEl.classList.add("red")
break
}
}
export function addOutput(id: string, output: string) {
const item = $$("li", output)
item.classList.add("output")
item.dataset.id = id
scrollback.append(item)
}

7
src/js/session.ts Normal file
View File

@ -0,0 +1,7 @@
////
// Each browser tab is a shell session. This means you can run multiple sessions
// in the same browser.
import { randomID } from "../shared/utils.js"
export const sessionID = randomID()

View File

@ -1,8 +1,20 @@
////
// The shell runs on the server and processes input, returning output.
import type { Message } from "../shared/message.js"
import { addInput, setStatus, addOutput } from "./scrollback.js"
import { send } from "./websocket.js"
import { randomID } from "../shared/utils.js"
export function runCommand(input: string) {
const id = randomID()
addInput(id, input)
send({ id, type: "input", data: input })
}
// message received from server
export function handleMessage(e: MessageEvent) {
const data = JSON.parse(e.data)
addOutput(data.id, data.data)
setStatus(data.id, "ok")
}

View File

@ -2,6 +2,8 @@
// The terminal communicates with the shell via websockets.
import type { Message } from "../shared/message.js"
import { sessionID } from "./session.js"
import { handleMessage } from "./shell.js"
let ws: WebSocket | null = null
@ -12,17 +14,25 @@ export function startConnection() {
ws = new WebSocket(url)
ws.onopen = () => console.log('WS connected')
ws.onmessage = e => console.log('WS message:', e.data)
ws.onmessage = handleMessage
ws.onclose = () => setTimeout(startConnection, 1000) // simple retry
ws.onerror = () => ws?.close()
}
// send any message
export function send(msg: Message) {
if (!msg.session) msg.session = sessionID
ws?.readyState === 1 && ws.send(JSON.stringify(msg))
console.log("-> send", msg)
}
// close it... plz don't do this, though
export function close() {
ws?.close(1000, 'bye')
}
// register a local listener
export function onMessage(callback: (e: MessageEvent) => void) {
if (!ws) return
ws.onmessage = callback
}

View File

@ -54,7 +54,8 @@ app.get("/ws", upgradeWebSocket((c) => {
return {
onMessage(event, ws) {
console.log(`Message from client: ${event.data}`)
ws.send('Hello from server!')
const data = JSON.parse(event.data.toString())
ws.send(JSON.stringify({ id: data.id, data: "Hi there!" }))
},
onClose: () => {
console.log('Connection closed')

View File

@ -1,6 +1,6 @@
export type Message = {
session: string
session?: string
id: string
type: "output"
type: "input" | "output"
data: any
}

3
src/shared/utils.ts Normal file
View File

@ -0,0 +1,3 @@
export function randomID(): string {
return Math.random().toString(36).slice(7)
}