Compare commits

..

11 Commits

Author SHA1 Message Date
Chris Wanstrath
8a6e95a500 sys -> root 2025-10-02 11:11:56 -07:00
ea7d538618 wheee 2025-10-01 22:19:52 -07:00
bab54e74d7 fix bug 2025-10-01 22:19:35 -07:00
c8704cf8fd fix js imports 2025-10-01 22:16:38 -07:00
8bfb6f2105 note 2025-10-01 21:58:27 -07:00
4276363a37 projects overhaul 2025-10-01 21:57:29 -07:00
dda1cc1f21 some stuff 2025-10-01 21:39:43 -07:00
e88627693c new project structure 2025-10-01 20:44:00 -07:00
f9792eb31c start moving to new project structure 2025-10-01 20:43:58 -07:00
89d850b55f remember your "mode" 2025-10-01 19:17:44 -07:00
07840aabd8 frontend message dispatch 2025-10-01 18:56:34 -07:00
61 changed files with 320 additions and 273 deletions

View File

@ -22,6 +22,8 @@ And to make sure DNS is working:
## Local Dev
Running the server will create `~/nose` for you to play with.
bun install
bun dev
open localhost:3000
@ -79,10 +81,10 @@ https://wakamaifondue.com/
- [x] public tunnel for your NOSE webapps
- [x] public tunnel lives through reboots
- [ ] tunnel to the terminal
- [ ] remember your "mode"
- [x] remember your "mode"
- [ ] `nose` CLI
- [ ] status bar on terminal UX
- [ ] "project"-based rehaul
- [x] "project"-based rehaul
- [x] self updating NOSE server
- [x] `pub/` static hosting in webapps
- [x] upload files to projects

View File

@ -5,7 +5,6 @@ import { readdirSync } from "fs"
import { join, extname } from "path"
import type { CommandOutput } from "@/shared/types"
import { NOSE_WWW } from "@/config"
import { isBinaryFile } from "@/utils"
import { projectName, projectDir } from "@/project"
import { sessionGet } from "@/session"
@ -21,10 +20,6 @@ export default async function (path: string) {
files.push(file.name)
}
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
if (!files.includes(path))
return { error: `file not found: ${path}` }

View File

@ -4,7 +4,6 @@ import { readdirSync } from "fs"
import { join, extname } from "path"
import type { CommandOutput } from "@/shared/types"
import { NOSE_WWW } from "@/config"
import { isBinaryFile } from "@/utils"
import { countChar } from "@/shared/utils"
import { projectName, projectDir } from "@/project"
@ -20,10 +19,6 @@ export default async function (path: string) {
files.push(file.name)
}
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
if (files.includes(path))
return await readFile(join(root, path))
else

View File

@ -2,7 +2,7 @@
//
// Show some debugging information.
import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_DIR, GIT_SHA } from "@/config"
import { NOSE_STARTED, NOSE_ROOT_BIN, NOSE_BIN, NOSE_DATA, NOSE_DIR, GIT_SHA } from "@/config"
import { highlightToHTML } from "../lib/highlight"
export default function () {
@ -14,7 +14,9 @@ export default function () {
`USER=${valueOrNone(process.env.USER)}`,
`PWD=${valueOrNone(process.env.PWD)}`,
`NOSE_STARTED=${NOSE_STARTED}`,
`NOSE_SYS_BIN="${NOSE_SYS_BIN}"`,
`NOSE_BIN="${NOSE_BIN}"`,
`NOSE_ROOT_BIN="${NOSE_ROOT_BIN}"`,
`NOSE_DATA="${NOSE_DATA}"`,
`NOSE_DIR="${NOSE_DIR}"`,
`GIT_SHA="${GIT_SHA}"`,
].join("\n"))

View File

@ -2,13 +2,13 @@
import { readdirSync } from "fs"
import { join } from "path"
import { NOSE_SYS_BIN } from "@/config"
import { NOSE_BIN } from "@/config"
export default async function () {
let games = await Promise.all(readdirSync(NOSE_SYS_BIN, { withFileTypes: true }).map(async file => {
let games = await Promise.all(readdirSync(NOSE_BIN, { withFileTypes: true }).map(async file => {
if (!file.isFile()) return
const code = await Bun.file(join(NOSE_SYS_BIN, file.name)).text()
const code = await Bun.file(join(NOSE_BIN, file.name)).text()
if (/^export const game\s*=\s*true\s*;?\s*$/m.test(code))
return file.name.replace(".tsx", "").replace(".ts", "")

View File

@ -1,12 +1,10 @@
// Look around.
import { readdirSync } from "fs"
import { NOSE_WWW } from "@/config"
import { projectName, projectDir } from "@/project"
import { sessionGet } from "@/session"
export default function () {
const project = projectName()
const root = sessionGet("cwd") || projectDir()
let files: string[] = []
@ -15,10 +13,6 @@ export default function () {
files.push(file.isDirectory() ? `${file.name}/` : file.name)
}
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
return <>
{root !== projectDir() && <a href="#cd ..;ls">..</a>}
{files.map(file =>

View File

@ -4,23 +4,21 @@
import { mkdirSync, writeFileSync } from "fs"
import { join } from "path"
import { apps } from "@/webapp"
import { NOSE_WWW } from "@/config"
import { isDir } from "@/utils"
import { projects } from "@/project"
import { NOSE_DIR } from "@/config"
import load from "./load"
export default function (project: string) {
if (!project) throw "usage: new <project name>"
if (apps().includes(project)) throw `${project} already exists`
if (projects().includes(project)) throw `${project} already exists`
if (!isDir(NOSE_WWW)) throw `no www dir! make one in a real shell:\n$ mkdir -p ${NOSE_WWW}`
mkdirSync(join(NOSE_WWW, project))
writeFileSync(join(NOSE_WWW, project, `index.ts`), `export default (c: Context) =>\n "Hello, world!"`)
const dir = join(NOSE_DIR, project, "bin")
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `index.ts`), `export default (c: Context) =>\n "Hello, world!"`)
load(project)
return `created ${project}`
return `Created ${project}`
}

View File

@ -1,10 +1,7 @@
// Print the currently loaded project.
import { sessionGet } from "@/session"
import { projectName } from "@/project"
export default function () {
const state = sessionGet()
if (!state) return { error: "no state" }
return state?.project || "none"
return projectName()
}

View File

@ -1,13 +1,10 @@
// Show the projects on this NOSEputer.
import { apps } from "@/webapp"
import { sessionGet } from "@/session"
import { projects, projectName } from "@/project"
export default function () {
const state = sessionGet()
if (!state) return { error: "no state" }
const project = projectName()
return <>
{apps().map(app => <a href={`#load ${app}`} class={app === state.project ? "magenta" : ""}>{app}</a>)}
{projects().map(app => <a href={`#load ${app}`} class={app === project ? "magenta" : ""}>{app}</a>)}
</>
}

View File

@ -1,6 +1,6 @@
// Reboot the whole computer! Careful!
export default async function reboot() {
setTimeout(async () => await Bun.$`reboot`, 1000)
console.log("REBOOTING...")
return {
text: "Rebooting... This will take about 10 seconds.",

View File

@ -1,6 +1,6 @@
// Restart the NOSE server.
export default function restart() {
setTimeout(() => process.exit(), 1000)
console.log("RESTARTING...")
return {
text: "Restarting... This will take a second or two.",

View File

@ -65,9 +65,6 @@ export function draw(game: GameContext) {
const offsetX = (game.width - boardW) / 2
const offsetY = (game.height - boardH) / 2
console.log("X", offsetX)
console.log("Y", offsetY)
const c = game.ctx
c.save()
c.translate(offsetX, offsetY)

View File

@ -1,3 +1,4 @@
// The git sha for the running server.
import { GIT_SHA } from "@/config"
export default function () {
return GIT_SHA

3
nose/chris/bin/chris.ts Normal file
View File

@ -0,0 +1,3 @@
export default function () {
return "chris works!"
}

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

3
nose/corey/bin/corey.ts Normal file
View File

@ -0,0 +1,3 @@
export default function () {
return "corey works!"
}

View File

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

3
nose/hello/bin/hello.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function () {
return <h1>HELLO!</h1>
}

View File

@ -1,2 +1,2 @@
export default () =>
"pong"
"pong"

2
nose/root/bin/root.ts Normal file
View File

@ -0,0 +1,2 @@
export default () =>
"root online"

View File

@ -1,5 +1,5 @@
////
// Manages the commands on disk, in NOSE_SYS_BIN and NOSE_BIN
// Manages the commands on disk, in NOSE_ROOT_BIN and NOSE_BIN
import { Glob } from "bun"
import { watch } from "fs"
@ -7,34 +7,49 @@ import { join } from "path"
import { isFile } from "./utils"
import { sendAll } from "./websocket"
import { expectDir } from "./utils"
import { NOSE_SYS_BIN, NOSE_BIN } from "./config"
import { unique } from "./shared/utils"
import { projectBin, projectName } from "./project"
import { DEFAULT_PROJECT, NOSE_DIR, NOSE_ROOT_BIN, NOSE_BIN } from "./config"
export function initCommands() {
startWatchers()
}
export async function commands(): Promise<string[]> {
return (await findCommands(NOSE_SYS_BIN)).concat(await findCommands(NOSE_BIN))
export async function commands(project = DEFAULT_PROJECT): Promise<string[]> {
let cmds = (await findCommands(NOSE_BIN))
.concat(await findCommands(NOSE_ROOT_BIN))
if (project !== DEFAULT_PROJECT)
cmds = cmds.concat(await findCommands(projectBin()))
return unique(cmds).sort()
}
export async function findCommands(path: string): Promise<string[]> {
const glob = new Glob("**/*.{ts,tsx}")
let list: string[] = []
for await (const file of glob.scan(path)) {
for await (const file of glob.scan(path))
list.push(file.replace(".tsx", "").replace(".ts", ""))
}
return list
}
export function commandPath(cmd: string): string | undefined {
return [
join(NOSE_SYS_BIN, cmd + ".ts"),
join(NOSE_SYS_BIN, cmd + ".tsx"),
let paths = [
join(NOSE_BIN, cmd + ".ts"),
join(NOSE_BIN, cmd + ".tsx")
].find((path: string) => isFile(path))
join(NOSE_BIN, cmd + ".tsx"),
join(NOSE_ROOT_BIN, cmd + ".ts"),
join(NOSE_ROOT_BIN, cmd + ".tsx"),
]
if (projectName() !== DEFAULT_PROJECT)
paths = paths.concat(
join(projectBin(), cmd + ".ts"),
join(projectBin(), cmd + ".tsx"),
)
return paths.find((path: string) => isFile(path))
}
export function commandExists(cmd: string): boolean {
@ -53,16 +68,17 @@ export async function loadCommandModule(cmd: string) {
return await import(path + "?t+" + Date.now())
}
let sysCmdWatcher
let usrCmdWatcher
let noseDirWatcher
let binCmdWatcher
function startWatchers() {
if (!expectDir(NOSE_BIN)) return
if (!expectDir(NOSE_ROOT_BIN)) return
sysCmdWatcher = watch(NOSE_SYS_BIN, async (event, filename) =>
sendAll({ type: "commands", data: await commands() })
)
usrCmdWatcher = watch(NOSE_BIN, async (event, filename) => {
binCmdWatcher = watch(NOSE_BIN, async (event, filename) => {
sendAll({ type: "commands", data: await commands() })
})
noseDirWatcher = watch(NOSE_DIR, async (event, filename) =>
sendAll({ type: "commands", data: await commands() })
)
}

View File

@ -4,13 +4,12 @@ import { untilde } from "./utils"
export const NOSE_ICON = ` ͡° ͜ʖ ͡°`
export const NOSE_SYS_BIN = resolve("./bin")
export const NOSE_BIN = resolve("./bin")
export const NOSE_DATA = resolve("./data")
export const NOSE_DIR = resolve(untilde(process.env.NOSE_DIR || "./nose"))
export const NOSE_BIN = join(NOSE_DIR, "bin")
export const NOSE_WWW = join(NOSE_DIR, "www")
export const NOSE_DATA = resolve("./data")
export const DEFAULT_PROJECT = "root"
export const NOSE_ROOT_BIN = join(NOSE_DIR, DEFAULT_PROJECT, "bin")
export const NOSE_STARTED = Date.now()
export const GIT_SHA = (await $`git rev-parse --short HEAD`.text()).trim()

View File

@ -73,6 +73,7 @@
a {
color: var(--cyan);
display: inline-block;
}
a:visited {

View File

@ -5,6 +5,7 @@ import { basename } from "path"
import type { Message } from "./shared/types"
import { runCommand } from "./shell"
import { send } from "./websocket"
import { setState } from "./state"
export async function dispatchMessage(ws: any, msg: Message) {
switch (msg.type) {
@ -14,6 +15,9 @@ export async function dispatchMessage(ws: any, msg: Message) {
case "save-file":
await saveFileMessage(ws, msg); break
case "ui:mode":
setState("ui:mode", msg.data); break
default:
send(ws, { type: "error", data: `unknown message: ${msg.type}` })
}

View File

@ -4,7 +4,7 @@
import { watch } from "fs"
import { apps } from "./webapp"
import { expectDir } from "./utils"
import { NOSE_WWW } from "./config"
import { NOSE_DIR } from "./config"
import { expectShellCmd } from "./utils"
export const dnsEntries: Record<string, any> = {}
@ -47,11 +47,11 @@ export function publishAppDNS(app: string) {
return dnsEntries[app]
}
let wwwWatcher
let dnsWatcher
function startWatcher() {
if (!expectDir(NOSE_WWW)) return
if (!expectDir(NOSE_DIR)) return
wwwWatcher = watch(NOSE_WWW, (event, filename) => {
dnsWatcher = watch(NOSE_DIR, (event, filename) => {
const www = apps()
www.forEach(publishAppDNS)
for (const name in dnsEntries)

View File

@ -58,7 +58,6 @@ export function routes(def: Record<string, Handler>): Hono {
const method = parts[0] || "GET"
const path = parts[1] || "/"
console.log(method, path, def[key])
//@ts-ignore
app.on(method, path, async c => toResponse(await def[key](c)))
}

View File

@ -17,7 +17,7 @@ export const Layout: FC = async ({ children, title }) => (
</head>
<body data-mode="tall">
<main>
<div id="content">
<div id="content" style="display:none">
{children}
</div>
</main>

View File

@ -1,20 +1,30 @@
////
// temporary hack for browser commands
import { scrollback } from "./dom.js"
import { resize } from "./resize.js"
import { autoScroll } from "./scrollback.js"
import { sessionId } from "./session.js"
import type { CommandOutput } from "../shared/types"
import { scrollback, content } from "./dom"
import { resize } from "./resize"
import { autoScroll } from "./scrollback"
import { sessionId } from "./session"
import { send } from "./websocket"
export const commands: string[] = []
export const browserCommands: Record<string, () => any> = {
export const browserCommands: Record<string, (...args: string[]) => void | Promise<void> | CommandOutput> = {
"browser-session": () => sessionId,
clear: () => scrollback.innerHTML = "",
commands: () => commands.join(" "),
commands: () => {
return { html: "<div>" + commands.map(cmd => `<a href="#help ${cmd}">${cmd}</a>`).join("") + "</div>" }
},
fullscreen: () => document.body.requestFullscreen(),
mode: () => {
document.body.dataset.mode = document.body.dataset.mode === "tall" ? "cinema" : "tall"
mode: (mode?: string) => {
if (!mode) {
mode = document.body.dataset.mode === "tall" ? "cinema" : "tall"
send({ type: "ui:mode", data: mode })
}
content.style.display = ""
document.body.dataset.mode = mode
resize()
autoScroll()
},
@ -26,4 +36,5 @@ export function cacheCommands(cmds: string[]) {
commands.push(...cmds)
commands.push(...Object.keys(browserCommands))
commands.sort()
console.log("CMDS", commands)
}

View File

@ -1,8 +1,8 @@
////
// tab completion
import { cmdInput } from "./dom.js"
import { commands } from "./commands.js"
import { cmdInput } from "./dom"
import { commands } from "./commands"
export function initCompletion() {
cmdInput.addEventListener("keydown", handleCompletion)

View File

@ -1,7 +1,7 @@
////
// Blinking c64 cursor
import { cmdInput, $ } from "./dom.js"
import { cmdInput, $ } from "./dom"
const cursor = "Û"
let cmdCursor: HTMLTextAreaElement

32
src/js/dispatch.ts Normal file
View File

@ -0,0 +1,32 @@
import type { Message } from "@/shared/types"
import { cacheCommands } from "./commands"
import { handleOutput } from "./scrollback"
import { handleStreamStart, handleStreamAppend, handleStreamReplace, handleStreamEnd } from "./stream"
import { handleGameStart } from "./game"
import { browserCommands } from "./commands"
// message received from server
export async function dispatchMessage(msg: Message) {
switch (msg.type) {
case "output":
handleOutput(msg); break
case "commands":
cacheCommands(msg.data as string[]); break
case "error":
console.error(msg.data); break
case "stream:start":
handleStreamStart(msg); break
case "stream:end":
handleStreamEnd(msg); break
case "stream:append":
handleStreamAppend(msg); break
case "stream:replace":
handleStreamReplace(msg); break
case "game:start":
await handleGameStart(msg); break
case "ui:mode":
browserCommands.mode?.(msg.data as string); break
default:
console.error("unknown message type", msg)
}
}

View File

@ -2,6 +2,7 @@
// DOM helpers and cached elements
// elements we know will be there... right?
export const content = $("content") as HTMLDivElement
export const cmdLine = $("command-line") as HTMLDivElement
export const cmdInput = $("command-textbox") as HTMLTextAreaElement
export const scrollback = $("scrollback") as HTMLUListElement

View File

@ -1,3 +1,7 @@
////
// Hack... works with the `upload` command.
// Dragging a file to the terminal fills in the <input type="file"/> on screen.
export function initDrop() {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, preventDefaults, false);

View File

@ -1,6 +1,6 @@
import { scrollback } from "./dom.js"
import { send } from "./websocket.js"
import { focusInput } from "./focus.js"
import { scrollback } from "./dom"
import { send } from "./websocket"
import { focusInput } from "./focus"
const INDENT_SIZE = 2

View File

@ -1,11 +1,11 @@
////
// We try to keep the command textbox focused at all times.
import { cmdInput } from "./dom.js"
import { cmdInput } from "./dom"
export function initFocus() {
window.addEventListener("click", focusHandler)
focusInput()
setTimeout(() => focusInput(), 10)
}
export function focusInput() {

View File

@ -1,10 +1,10 @@
////
// All forms are submitted via ajax.
import type { CommandResult, CommandOutput } from "../shared/types.js"
import { sessionId } from "./session.js"
import { setStatus, replaceOutput } from "./scrollback.js"
import { focusInput } from "./focus.js"
import type { CommandResult, CommandOutput } from "../shared/types"
import { sessionId } from "./session"
import { setStatus, replaceOutput } from "./scrollback"
import { focusInput } from "./focus"
export function initForm() {
document.addEventListener("submit", submitHandler)

View File

@ -1,10 +1,10 @@
import type { Message } from "../shared/types.js"
import { GameContext, type InputState } from "../shared/game.js"
import { focusInput } from "./focus.js"
import { $$ } from "./dom.js"
import { randomId } from "../shared/utils.js"
import { setStatus, addOutput, insert } from "./scrollback.js"
import { browserCommands } from "./commands.js"
import type { Message } from "../shared/types"
import { GameContext, type InputState } from "../shared/game"
import { focusInput } from "./focus"
import { $$ } from "./dom"
import { randomId } from "../shared/utils"
import { setStatus, addOutput, insert } from "./scrollback"
import { browserCommands } from "./commands"
const FPS = 30
const HEIGHT = 540

View File

@ -1,7 +1,7 @@
////
// Command input history storage and navigation.
import { cmdInput, cmdLine } from "./dom.js"
import { cmdInput, cmdLine } from "./dom"
const history: string[] = ["one", "two", "three"]
let idx = -1

View File

@ -1,11 +1,11 @@
import { runCommand } from "./shell.js"
import { focusInput } from "./focus.js"
import { runCommand } from "./shell"
import { focusInput } from "./focus"
export function initHyperlink() {
window.addEventListener("click", handleClick)
}
function handleClick(e: MouseEvent) {
async function handleClick(e: MouseEvent) {
const target = e.target
if (!(target instanceof HTMLElement)) return
@ -18,7 +18,7 @@ function handleClick(e: MouseEvent) {
if (href.startsWith("#")) {
e.preventDefault()
runCommand(href.slice(1))
await runCommand(href.slice(1))
focusInput()
}
}

View File

@ -1,17 +1,16 @@
////
// Terminal input is handled by a <textarea> and friends.
import { cmdInput, cmdLine } from "./dom.js"
import { runCommand } from "./shell.js"
import { resetHistory } from "./history.js"
import { countChar } from "../shared/utils.js"
import { cmdInput, cmdLine } from "./dom"
import { runCommand } from "./shell"
import { resetHistory } from "./history"
export function initInput() {
cmdInput.addEventListener("keydown", inputHandler)
cmdInput.addEventListener("paste", pasteHandler)
}
function inputHandler(event: KeyboardEvent) {
async function inputHandler(event: KeyboardEvent) {
const target = event.target as HTMLElement
if (target?.id !== cmdInput.id) return
@ -25,7 +24,7 @@ function inputHandler(event: KeyboardEvent) {
cmdLine.dataset.extended = "true"
} else if (event.key === "Enter") {
event.preventDefault()
runCommand(cmdInput.value)
await runCommand(cmdInput.value)
clearInput()
}

View File

@ -1,17 +1,17 @@
import { initCompletion } from "./completion.js"
import { initCursor } from "./cursor.js"
import { initDrop } from "./drop.js"
import { initEditor } from "./editor.js"
import { initFocus } from "./focus.js"
import { initForm } from "./form.js"
import { initGamepad } from "./gamepad.js"
import { initHistory } from "./history.js"
import { initHyperlink } from "./hyperlink.js"
import { initInput } from "./input.js"
import { initResize } from "./resize.js"
import { initScrollback } from "./scrollback.js"
import { startVramCounter } from "./vram.js"
import { startConnection } from "./websocket.js"
import { initCompletion } from "./completion"
import { initCursor } from "./cursor"
import { initDrop } from "./drop"
import { initEditor } from "./editor"
import { initFocus } from "./focus"
import { initForm } from "./form"
import { initGamepad } from "./gamepad"
import { initHistory } from "./history"
import { initHyperlink } from "./hyperlink"
import { initInput } from "./input"
import { initResize } from "./resize"
import { initScrollback } from "./scrollback"
import { startVramCounter } from "./vram"
import { startConnection } from "./websocket"
initCompletion()
initCursor()

View File

@ -2,9 +2,9 @@
// The scrollback shows your history of interacting with the shell.
// input, output, etc
import type { CommandOutput } from "../shared/types.js"
import { scrollback, cmdInput, $$ } from "./dom.js"
import { randomId } from "../shared/utils.js"
import type { Message, CommandOutput, CommandResult } from "../shared/types"
import { scrollback, cmdInput, $$ } from "./dom"
import { randomId } from "../shared/utils"
type InputStatus = "waiting" | "streaming" | "ok" | "error"
@ -142,3 +142,9 @@ function handleInputClick(e: MouseEvent) {
cmdInput.value = target.textContent
}
}
export function handleOutput(msg: Message) {
const result = msg.data as CommandResult
setStatus(msg.id!, result.status)
addOutput(msg.id!, result.output)
}

View File

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

View File

@ -1,19 +1,17 @@
////
// The shell runs on the server and processes input, returning output.
import type { Message, CommandResult, CommandOutput } from "../shared/types.js"
import { addInput, setStatus, addOutput, appendOutput, replaceOutput } from "./scrollback.js"
import { send } from "./websocket.js"
import { randomId } from "../shared/utils.js"
import { addToHistory } from "./history.js"
import { browserCommands, cacheCommands } from "./commands.js"
import { handleGameStart } from "./game.js"
import { addInput, setStatus, addOutput } from "./scrollback"
import { send } from "./websocket"
import { randomId } from "../shared/utils"
import { addToHistory } from "./history"
import { browserCommands } from "./commands"
export function runCommand(input: string) {
export async function runCommand(input: string) {
if (!input.trim()) return
if (input.includes(";")) {
input.split(";").forEach(cmd => runCommand(cmd.trim()))
input.split(";").forEach(async cmd => await runCommand(cmd.trim()))
return
}
@ -22,68 +20,14 @@ export function runCommand(input: string) {
addToHistory(input)
addInput(id, input)
const [cmd = "", ..._args] = input.split(" ")
const [cmd = "", ...args] = input.split(" ")
if (browserCommands[cmd]) {
const result = browserCommands[cmd]()
if (typeof result === "string")
addOutput(id, result)
const result = await browserCommands[cmd](...args)
if (result) addOutput(id, result)
setStatus(id, "ok")
} else {
send({ id, type: "input", data: input })
}
}
// message received from server
export async function handleMessage(msg: Message) {
switch (msg.type) {
case "output":
handleOutput(msg); break
case "commands":
cacheCommands(msg.data as string[]); break
case "error":
console.error(msg.data); break
case "stream:start":
handleStreamStart(msg); break
case "stream:end":
handleStreamEnd(msg); break
case "stream:append":
handleStreamAppend(msg); break
case "stream:replace":
handleStreamReplace(msg); break
case "game:start":
await handleGameStart(msg); break
default:
console.error("unknown message type", msg)
}
}
function handleOutput(msg: Message) {
const result = msg.data as CommandResult
setStatus(msg.id!, result.status)
addOutput(msg.id!, result.output)
}
function handleStreamStart(msg: Message) {
const id = msg.id!
const status = document.querySelector(`[data-id="${id}"].input .status`)
if (!status) return
addOutput(id, msg.data as CommandOutput)
status.classList.remove("yellow")
status.classList.add("purple")
}
function handleStreamAppend(msg: Message) {
appendOutput(msg.id!, msg.data as CommandOutput)
}
function handleStreamReplace(msg: Message) {
replaceOutput(msg.id!, msg.data as CommandOutput)
}
function handleStreamEnd(_msg: Message) {
}

25
src/js/stream.ts Normal file
View File

@ -0,0 +1,25 @@
import type { Message, CommandOutput } from "@/shared/types"
import { addOutput, appendOutput, replaceOutput } from "./scrollback"
export function handleStreamStart(msg: Message) {
const id = msg.id!
const status = document.querySelector(`[data-id="${id}"].input .status`)
if (!status) return
addOutput(id, msg.data as CommandOutput)
status.classList.remove("yellow")
status.classList.add("purple")
}
export function handleStreamAppend(msg: Message) {
appendOutput(msg.id!, msg.data as CommandOutput)
}
export function handleStreamReplace(msg: Message) {
replaceOutput(msg.id!, msg.data as CommandOutput)
}
export function handleStreamEnd(_msg: Message) {
}

View File

@ -1,7 +1,7 @@
////
// Fun vram counter at startup.
import { $ } from "./dom.js"
import { $ } from "./dom"
const vramCounter = $("vram-size")!

View File

@ -1,10 +1,10 @@
////
// The terminal communicates with the shell via websockets.
import type { Message } from "../shared/types.js"
import { sessionId } from "./session.js"
import { handleMessage } from "./shell.js"
import { addErrorMessage } from "./scrollback.js"
import type { Message } from "../shared/types"
import { sessionId } from "./session"
import { dispatchMessage } from "./dispatch"
import { addErrorMessage } from "./scrollback"
const MAX_RETRIES = 5
let retries = 0
@ -15,7 +15,7 @@ let ws: WebSocket | null = null
// open our websocket connection
export function startConnection() {
const url = new URL('/ws', location.href)
const url = new URL(`/ws?session=${sessionId}`, location.href)
url.protocol = url.protocol.replace('http', 'ws')
ws = new WebSocket(url)
@ -45,7 +45,7 @@ export function send(msg: Message) {
async function receive(e: MessageEvent) {
const data = JSON.parse(e.data) as Message
console.log("<- receive", data)
await handleMessage(data)
await dispatchMessage(data)
}
// close it... plz don't do this, though

View File

@ -1,27 +1,37 @@
////
////
// Helpers for working with projects in the CLI.
import { join } from "path"
import { readdirSync, type Dirent } from "fs"
import { sessionGet } from "./session"
import { appDir } from "./webapp"
import { DEFAULT_PROJECT, NOSE_DIR } from "./config"
import { isDir } from "./utils"
export function projectName(): string {
const state = sessionGet()
if (!state) throw "no state"
const project = state.project
if (!project) throw "no project loaded"
return project
return state.project || DEFAULT_PROJECT
}
export function projectDir(): string {
const root = appDir(projectName())
if (!root) throw "error loading project"
export function projects(): string[] {
return readdirSync(NOSE_DIR, { withFileTypes: true })
.filter(file => file.isDirectory())
.map(dir => dir.name)
.sort()
}
export function projectDir(name = projectName()): string {
const root = join(NOSE_DIR, name)
if (!isDir(root))
throw `no project found at ${root}`
return root
}
export function projectFiles(): Dirent[] {
return readdirSync(projectDir(), { recursive: true, withFileTypes: true })
export function projectBin(name = projectName()): string {
return join(projectDir(), "bin")
}
export function projectFiles(name = projectName()): Dirent[] {
return readdirSync(projectDir(name), { recursive: true, withFileTypes: true })
}

View File

@ -7,7 +7,7 @@ import { prettyJSON } from "hono/pretty-json"
import color from "kleur"
import type { Message } from "./shared/types"
import { NOSE_ICON, NOSE_BIN, NOSE_WWW, NOSE_DATA, NOSE_DIR } from "./config"
import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN } from "./config"
import { transpile, isFile, tilde, isDir } from "./utils"
import { serveApp } from "./webapp"
import { commands, commandPath, loadCommandModule } from "./commands"
@ -16,6 +16,7 @@ import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocke
import { initSneakers, disconnectSneakers } from "./sneaker"
import { dispatchMessage } from "./dispatch"
import { fatal } from "./fatal"
import { getState } from "./state"
import { Layout } from "./html/layout"
import { Terminal } from "./html/terminal"
@ -126,11 +127,16 @@ app.get("/", c => c.html(<Layout><Terminal /></Layout>))
// websocket
//
app.get("/ws", upgradeWebSocket(async c => {
return {
app.get("/ws", c => {
const _sessionId = c.req.query("session")
return upgradeWebSocket(c, {
async onOpen(_e, ws) {
addWebsocket(ws)
send(ws, { type: "commands", data: await commands() })
const mode = getState("ui:mode")
if (mode) send(ws, { type: "ui:mode", data: mode })
},
async onMessage(event, ws) {
let data: Message | undefined
@ -148,8 +154,8 @@ app.get("/ws", upgradeWebSocket(async c => {
await dispatchMessage(ws, data)
},
onClose: (event, ws) => removeWebsocket(ws)
}
}))
})
})
//
// hot reload mode cleanup
@ -187,10 +193,10 @@ if (process.env.NODE_ENV === "production") {
//
console.log(color.cyan(NOSE_ICON))
console.log(color.blue("NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
console.log(color.blue("NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
console.log(color.blue("NOSE_BIN:"), color.yellow(tilde(NOSE_BIN)))
console.log(color.blue("NOSE_WWW:"), color.yellow(tilde(NOSE_WWW)))
console.log(color.blue(" NOSE_BIN:"), color.yellow(tilde(NOSE_BIN)))
console.log(color.blue(" NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
console.log(color.blue(" NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
console.log(color.blue("NOSE_ROOT_BIN:"), color.yellow(tilde(NOSE_ROOT_BIN)))
await initNoseDir()
initCommands()

View File

@ -15,9 +15,16 @@ export type Session = {
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> }
export const ALS = g.__thread ??= new AsyncLocalStorage<Session>()
const sessions: Map<string, Session> = new Map()
export async function sessionRun(sessionId: string, fn: () => void | Promise<void>) {
const state = sessionStore(sessionId)
return await ALS.run(state, async () => fn())
}
export function sessionGet(key?: keyof Session): Session | any | undefined {
const store = ALS.getStore()
if (!store) return
if (!store) throw "sessionGet() called outside of ALS.run"
if (key) return store[key]
@ -26,6 +33,18 @@ export function sessionGet(key?: keyof Session): Session | any | undefined {
export function sessionSet(key: keyof Session, value: any) {
const store = ALS.getStore()
if (!store) return
if (!store) throw "sessionSet() called outside of ALS.run"
store[key] = value
}
export function sessionStore(sessionId: string, taskId?: string, ws?: any): Session {
let state = sessions.get(sessionId)
if (!state) {
state = { sessionId: sessionId, project: "" }
sessions.set(sessionId, state)
}
if (taskId)
state.taskId = taskId
if (ws) state.ws = ws
return state
}

View File

@ -8,6 +8,7 @@ export type Message = {
export type MessageType = "error" | "input" | "output" | "commands" | "save-file"
| "game:start"
| "stream:start" | "stream:end" | "stream:append" | "stream:replace"
| "ui:mode"
export type CommandOutput = string | string[]
| { text: string, script?: string }

View File

@ -33,4 +33,9 @@ export function randomElement<T>(list: T[]): T | undefined {
export function randomIndex<T>(list: T[]): number | undefined {
if (!list.length) return
return rng(0, list.length - 1)
}
// unique([1,1,2,2,3,3]) #=> [1,2,3]
export function unique<T>(array: T[]): T[] {
return [...new Set(array)]
}

View File

@ -2,21 +2,14 @@
// Runs commands and such on the server.
// This is the "shell" - the "terminal" is the browser UI.
import type { CommandResult, CommandOutput } from "./shared/types"
import type { Session } from "./session"
import type { CommandResult } from "./shared/types"
import { sessionStore } from "./session"
import { commandExists, loadCommandModule } from "./commands"
import { ALS } from "./session"
const sessions: Map<string, Session> = new Map()
export async function runCommand(sessionId: string, taskId: string, input: string, ws?: any): Promise<CommandResult> {
const [cmd = "", ...args] = input.split(" ")
if (!commandExists(cmd))
return { status: "error", output: `${cmd} not found` }
return runCommandFn({ sessionId, taskId, ws }, async () => exec(cmd, args))
}
export async function runCommandFn(
@ -24,7 +17,7 @@ export async function runCommandFn(
fn: () => Promise<CommandResult>
): Promise<CommandResult> {
try {
const state = getState(sessionId, taskId, ws)
const state = sessionStore(sessionId, taskId, ws)
return processExecOutput(await ALS.run(state, async () => fn()))
} catch (err) {
return { status: "error", output: errorMessage(err) }
@ -32,6 +25,9 @@ export async function runCommandFn(
}
async function exec(cmd: string, args: string[]): Promise<CommandResult> {
if (!commandExists(cmd))
return { status: "error", output: `${cmd} not found` }
const module = await loadCommandModule(cmd)
if (module?.game)
@ -67,18 +63,6 @@ export function processExecOutput(output: string | any): CommandResult {
}
}
function getState(sessionId: string, taskId?: string, ws?: any): Session {
let state = sessions.get(sessionId)
if (!state) {
state = { sessionId: sessionId, project: "" }
sessions.set(sessionId, state)
}
if (taskId)
state.taskId = taskId
if (ws) state.ws = ws
return state
}
function errorMessage(error: Error | any): string {
if (!(error instanceof Error))
return String(error)

View File

@ -31,7 +31,6 @@ export function expectDir(path: string): boolean {
export async function expectShellCmd(cmd: string): Promise<boolean> {
try {
await $`which ${cmd}`
console.log("WHICH", cmd)
return true
} catch {
setFatal(`Missing critical dependency: avahi-publish`)

View File

@ -4,10 +4,10 @@
import type { Child } from "hono/jsx"
import { type Context, Hono } from "hono"
import { renderToString } from "hono/jsx/dom/server"
import { join, dirname } from "path"
import { join } from "path"
import { readdirSync } from "fs"
import { NOSE_WWW } from "./config"
import { NOSE_DIR } from "./config"
import { isFile, isDir } from "./utils"
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
@ -35,39 +35,32 @@ export async function serveApp(c: Context, subdomain: string): Promise<Response>
export function apps(): string[] {
const apps: string[] = []
for (const entry of readdirSync(NOSE_WWW))
apps.push(entry.replace(/\.tsx?/, ""))
for (const entry of readdirSync(NOSE_DIR))
if (isApp(entry))
apps.push(entry)
return apps.sort()
}
function isApp(name: string): boolean {
return isFile(join(NOSE_DIR, name, "index.ts"))
|| isFile(join(NOSE_DIR, name, "index.tsx"))
|| isDir(join(NOSE_DIR, name, "pub"))
}
export function appDir(name: string): string | undefined {
const path = [
`${name}.ts`,
`${name}.tsx`,
name
]
.map(path => join(NOSE_WWW, path))
.flat()
.filter(path => /\.tsx?$/.test(path) ? isFile(path) : isDir(path))[0]
if (!path) return
return /\.tsx?$/.test(path) ? dirname(path) : path
if (isApp(name))
return join(NOSE_DIR, name)
}
async function findApp(name: string): Promise<App | undefined> {
const paths = [
`${name}.ts`,
`${name}.tsx`,
join(name, "index.ts"),
join(name, "index.tsx")
]
let app
for (const path of paths) {
app = await loadApp(join(NOSE_WWW, path))
const app = await loadApp(join(NOSE_DIR, path))
if (app) return app
}
}