Compare commits

...

11 Commits

Author SHA1 Message Date
Chris Wanstrath
713754fece update installers 2025-09-26 11:24:48 -07:00
Chris Wanstrath
662fc42ff7 relative 2025-09-26 11:19:56 -07:00
Chris Wanstrath
7534b65925 instructions 2025-09-26 10:28:41 -07:00
Chris Wanstrath
8efc808af2 update install scripts 2025-09-26 10:22:13 -07:00
Chris Wanstrath
12f4ce9657 break everything 2025-09-26 10:16:59 -07:00
Chris Wanstrath
7f695eb9eb comments, dns 2025-09-26 09:57:22 -07:00
Chris Wanstrath
acabf8c4c6 State -> Session 2025-09-26 09:47:45 -07:00
Chris Wanstrath
ef282e6df7 fun 2025-09-26 09:44:23 -07:00
bbff3324f3 update install/deploy 2025-09-24 21:19:20 -07:00
fc0a19c45d update update 2025-09-24 21:14:02 -07:00
1010c6586a updater 2025-09-24 21:13:09 -07:00
33 changed files with 308 additions and 209 deletions

View File

@ -1,15 +1,20 @@
# NOSE:pluto # NOSE:pluto
- [x] Hosts valtown-style Bun apps (for your home network) ## Installation
- [x] Provides a NOSE terminal/shell GUI
- [x] Runs one-shot TypeScript commands (via NOSE terminal) You just need to make a "nose" user on your RPi and make sure you can ssh in.
- [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 Then run `bun remote:install`.
When it's done (it'll reboot) visit:
http://nose-pluto.local
## Local Dev ## Local Dev
bun install bun install
bun dev bun dev
open localhost:3000
## Commands ## Commands
@ -26,3 +31,15 @@ They can also `throw` to display an error.
Use this to examine what's inside the C64 .woff2 font file in public/vendor: Use this to examine what's inside the C64 .woff2 font file in public/vendor:
https://wakamaifondue.com/ https://wakamaifondue.com/
## Pluto Goals: Phase 1
- [x] Hosts valtown-style Bun apps (for your home network)
- [x] Provides a NOSE terminal/shell GUI
- [x] 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
## Pluto Goals: Phase 2
- [ ] coming soon

View File

@ -4,7 +4,7 @@ import { join, extname } from "path"
import type { CommandOutput } from "app/src/shared/types" import type { CommandOutput } from "app/src/shared/types"
import { NOSE_WWW } from "app/src/config" import { NOSE_WWW } from "app/src/config"
import { getState } from "app/src/state" import { getState } from "@/session"
import { appPath } from "app/src/webapp" import { appPath } from "app/src/webapp"
import { isBinaryFile } from "app/src/utils" import { isBinaryFile } from "app/src/utils"
import { highlight } from "../lib/highlight" import { highlight } from "../lib/highlight"

View File

@ -4,7 +4,7 @@ import { join, extname } from "path"
import type { CommandOutput } from "app/src/shared/types" import type { CommandOutput } from "app/src/shared/types"
import { NOSE_WWW } from "app/src/config" import { NOSE_WWW } from "app/src/config"
import { getState } from "app/src/state" import { getState } from "@/session"
import { appPath } from "app/src/webapp" import { appPath } from "app/src/webapp"
import { isBinaryFile } from "app/src/utils" import { isBinaryFile } from "app/src/utils"
import { countChar } from "app/src/shared/utils" import { countChar } from "app/src/shared/utils"

View File

@ -1,5 +1,5 @@
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
import { getState } from "app/src/state" import { getState } from "@/session"
export default function (project: string) { export default function (project: string) {
const state = getState() const state = getState()

View File

@ -1,6 +1,6 @@
import { readdirSync } from "fs" import { readdirSync } from "fs"
import { NOSE_WWW } from "app/src/config" import { NOSE_WWW } from "app/src/config"
import { getState } from "app/src/state" import { getState } from "@/session"
import { appPath } from "app/src/webapp" import { appPath } from "app/src/webapp"
export default function () { export default function () {

View File

@ -1,4 +1,4 @@
import { getState } from "app/src/state" import { getState } from "@/session"
export default function () { export default function () {
const state = getState() const state = getState()

View File

@ -1,5 +1,5 @@
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
import { getState } from "app/src/state" import { getState } from "@/session"
export default function () { export default function () {
const state = getState() const state = getState()

17
app/nose/bin/update.ts Normal file
View File

@ -0,0 +1,17 @@
import { $ } from "bun"
export default async function () {
if (process.env.NODE_ENV !== "production") {
return { error: "Can only update in production." }
}
const { stdout } = await $`cd .. && git pull`.quiet()
const out = stdout.toString()
if (/up to date/.test(out)) {
return "Up to date."
} else {
setTimeout(() => process.exit(), 1000)
return "Restarting in 1 second..."
}
}

View File

@ -2,5 +2,6 @@
# It isn't enough to modify this yet. # It isn't enough to modify this yet.
# You also need to manually update the nose-pluto.service file. # You also need to manually update the nose-pluto.service file.
HOST="${HOST:-chris@nose-pluto.local}" HOST="${HOST:-nose@nose-pluto.local}"
DEST="${DEST:-~/pluto}" DEST="${DEST:-~/nose}"
REPO="${REPO:-https://git.nose.space/defunkt/nose-pluto}"

View File

@ -1,39 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e
## # Get absolute path of this scripts directory
# deploys from your dev machine to your NOSEputer SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/../.."
set -euo pipefail # Run deploy + config with absolute paths
source "$ROOT_DIR/app/scripts/config.sh"
source ./scripts/config.sh # Run remote install on the target
SOCK="$HOME/.ssh/cm-%r@%h:%p" ssh $HOST "cd $DEST && git pull && bun install && sudo systemctl restart nose-pluto.service"
# 1) Open a master connection (prompts once)
ssh -MNf -o ControlMaster=yes -o ControlPersist=120 \
-o ControlPath="$SOCK" "$HOST"
# 2) rsync (reuses the connection)
# ensure our directory exists
ssh -o ControlPath="$SOCK" "$HOST" "mkdir -p $DEST/app"
# destructive sync for /app
rsync -az --delete \
-e "ssh -o ControlPath=$SOCK" \
--exclude 'node_modules/' \
--exclude '.git/' \
../app/ "$HOST:$DEST/app/"
# additive sync for root, /bin, /www
rsync -az \
-e "ssh -o ControlPath=$SOCK" \
--exclude 'node_modules/' \
--exclude '.git/' \
--exclude 'app/' \
../ "$HOST:$DEST/"
# 3) remote deploy (reuses the connection)
ssh -o ControlPath="$SOCK" "$HOST" "cd $DEST && bun install --frozen-lockfile && sudo systemctl restart nose-pluto.service"
# 4) close the master connection
ssh -O exit -o ControlPath="$SOCK" "$HOST"

View File

@ -17,8 +17,11 @@ if [ ! -x "$BUN_SYMLINK" ]; then
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK" sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK" echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
else else
echo "Error: bun not found at $BUN_REAL" echo ">> Installing bun at $BUN_REAL"
exit 1 sudo apt install unzip
curl -fsSL https://bun.com/install | bash
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
fi fi
else else
echo "bun already available at $BUN_SYMLINK" echo "bun already available at $BUN_SYMLINK"
@ -42,5 +45,12 @@ sudo systemctl enable "$SERVICE_NAME"
echo ">> Starting (or restarting) $SERVICE_NAME" echo ">> Starting (or restarting) $SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME" sudo systemctl restart "$SERVICE_NAME"
echo ">> Done!" echo ">> Enabling kiosk mode"
mkdir -p ~/.config/labwc
cat > ~/.config/labwc/autostart <<'EOF'
chromium-browser --noerrdialogs --disable-infobars --kiosk http://localhost
EOF
echo ">> Done! Rebooting!"
systemctl status "$SERVICE_NAME" --no-pager -l systemctl status "$SERVICE_NAME" --no-pager -l
sudo reboot

View File

@ -4,11 +4,11 @@ After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
User=chris User=nose
WorkingDirectory=/home/chris/pluto/app WorkingDirectory=/home/nose/nose/app
Environment=PORT=80 Environment=PORT=80
Environment=NODE_ENV=production Environment=NODE_ENV=production
ExecStart=/home/chris/.bun/bin/bun start ExecStart=/home/nose/.bun/bin/bun start
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE

View File

@ -1,8 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e
## # Get absolute path of this scripts directory
# setup your NOSEputer from your dev machine SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/../.."
source ./app/scripts/config.sh # Run deploy + config with absolute paths
source "$ROOT_DIR/app/scripts/config.sh"
ssh $HOST "cd $DEST && ./app/scripts/install.sh && sudo systemctl start nose-pluto.service" # Run remote install on the target
ssh "$HOST" "git clone $REPO $DEST && cd $DEST && ./app/scripts/install.sh && sudo systemctl start nose-pluto.service"

View File

@ -1,2 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
ssh chris@nose-pluto.local "sudo systemctl restart nose-pluto.service" set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/../.."
source "$ROOT_DIR/app/scripts/config.sh"
ssh "$HOST" "sudo systemctl restart nose-pluto.service"

View File

@ -1,2 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
ssh chris@nose-pluto.local "sudo systemctl start nose-pluto.service" set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/../.."
source "$ROOT_DIR/app/scripts/config.sh"
ssh "$HOST" "sudo systemctl start nose-pluto.service"

View File

@ -1,2 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
ssh chris@nose-pluto.local "sudo systemctl stop nose-pluto.service" set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$SCRIPT_DIR/../.."
source "$ROOT_DIR/app/scripts/config.sh"
ssh "$HOST" "sudo systemctl stop nose-pluto.service"

View File

@ -1,3 +1,6 @@
////
// Manages the commands on disk, in NOSE_SYS_BIN and NOSE_BIN
import { Glob } from "bun" import { Glob } from "bun"
import { watch } from "fs" import { watch } from "fs"
import { sendAll } from "./websocket" import { sendAll } from "./websocket"

View File

@ -1,24 +1,33 @@
//// ////
// Dispatch Messages // Dispatch Messages received via WebSocket
import { basename } from "path" import { basename } from "path"
import type { Message } from "./shared/types" import type { Message } from "./shared/types"
import { runCommand } from "./shell" import { runCommand } from "./shell"
import { send } from "./websocket" import { send } from "./websocket"
import { isFile } from "./utils"
export async function dispatchMessage(ws: any, msg: Message) { export async function dispatchMessage(ws: any, msg: Message) {
if (msg.type === "input") { switch (msg.type) {
const result = await runCommand(msg.session || "", msg.id || "", msg.data as string) case "input":
send(ws, { id: msg.id, type: "output", data: result }) await inputMessage(ws, msg); break
} else if (msg.type === "save-file") { case "save-file":
if (msg.id && typeof msg.data === "string") { await saveFileMessage(ws, msg); break
await Bun.write(msg.id.replace("..", ""), msg.data, { createPath: true })
send(ws, { type: "output", data: { status: "ok", output: `saved ${basename(msg.id)}` } })
}
} else { default:
send(ws, { type: "error", data: `unknown message: ${msg.type}` }) send(ws, { type: "error", data: `unknown message: ${msg.type}` })
} }
} }
async function inputMessage(ws: any, msg: Message) {
const result = await runCommand(msg.session || "", msg.id || "", msg.data as string)
send(ws, { id: msg.id, type: "output", data: result })
}
async function saveFileMessage(ws: any, msg: Message) {
if (msg.id && typeof msg.data === "string") {
await Bun.write(msg.id.replace("..", ""), msg.data, { createPath: true })
send(ws, { type: "output", data: { status: "ok", output: `saved ${basename(msg.id)}` } })
}
}

55
app/src/dns.ts Normal file
View File

@ -0,0 +1,55 @@
////
// Publishes webapps as subdomains on your local network
import { watch } from "fs"
import { apps } from "./webapp"
import { expectDir } from "./utils"
import { NOSE_WWW } from "./config"
export const dnsEntries: Record<string, any> = {}
const { stdout: ipRaw } = await Bun.$`hostname -I | awk '{print $1}'`.quiet()
const { stdout: hostRaw } = await Bun.$`hostname`.quiet()
const ip = ipRaw.toString().trim()
const host = hostRaw.toString().trim()
let dnsInit = false
export async function initDNS() {
apps().forEach(publishAppDNS)
const signals = ["SIGINT", "SIGTERM"]
signals.forEach(sig =>
process.on(sig, () => {
for (const name in dnsEntries)
dnsEntries[name].kill("SIGTERM")
process.exit(0)
})
)
dnsInit = true
}
export function publishAppDNS(app: string) {
if (!dnsInit) throw "publishAppDNS() must be called after initDNS()"
if (process.env.NODE_ENV !== "production") return
if (!dnsEntries[app])
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])
return dnsEntries[app]
}
// exit process with error if no WWW dir
expectDir(NOSE_WWW)
const wwwWatcher = watch(NOSE_WWW, (event, filename) => {
const www = apps()
www.forEach(publishAppDNS)
for (const name in dnsEntries)
if (!www.includes(name)) {
dnsEntries[name].kill("SIGTERM")
delete dnsEntries[name]
}
})

67
app/src/helpers.tsx Normal file
View File

@ -0,0 +1,67 @@
////
// Helpers for writing NOSE webapps & cli commands
//
// Be *very careful* modifying this file, as people's scripts and www's will depend
// on the API. We need to eventually version it and provide backwards compat.
//
// Access them in your command or webapp:
// import { css } from "@nose"
import { Hono } from "hono"
import { type Handler, toResponse } from "./webapp"
//
// command helpers
//
// (none for now)
//
// webapp helpers
//
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
export function css(strings: TemplateStringsArray, ...values: any[]) {
return <style dangerouslySetInnerHTML={
{
__html: strings.reduce((result, str, i) => {
return result + str + (values[i] || '')
}, '')
}
} />
}
export function js(strings: TemplateStringsArray, ...values: any[]) {
return <script dangerouslySetInnerHTML={
{
__html: strings.reduce((result, str, i) => {
return transpiler.transformSync(result + str + (values[i] || ''))
}, '')
}
} />
}
// for defining routes in your NOSE webapp
// example:
// export default routes({
// "GET /": index,
// "GET /pets": pets
// })
export function routes(def: Record<string, Handler>): Hono {
const app = new Hono
for (const key in def) {
const parts = key.split(" ") // GET /path
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)))
}
return app
}

View File

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

View File

@ -4,14 +4,14 @@
import type { Message, CommandResult } from "../shared/types.js" import type { Message, CommandResult } from "../shared/types.js"
import { addInput, setStatus, addOutput } from "./scrollback.js" import { addInput, setStatus, addOutput } from "./scrollback.js"
import { send } from "./websocket.js" import { send } from "./websocket.js"
import { randomID } from "../shared/utils.js" import { randomId } from "../shared/utils.js"
import { addToHistory } from "./history.js" import { addToHistory } from "./history.js"
import { browserCommands, cacheCommands } from "./commands.js" import { browserCommands, cacheCommands } from "./commands.js"
export function runCommand(input: string) { export function runCommand(input: string) {
if (!input.trim()) return if (!input.trim()) return
const id = randomID() const id = randomId()
addToHistory(input) addToHistory(input)
addInput(id, input) addInput(id, input)

View File

@ -1,3 +1,6 @@
////
// Web server that serves shell commands, websocket connections, etc
import { Hono } from "hono" import { Hono } from "hono"
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun" import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
import { prettyJSON } from "hono/pretty-json" import { prettyJSON } from "hono/pretty-json"
@ -6,12 +9,13 @@ import color from "kleur"
import type { Message } from "./shared/types" import type { Message } from "./shared/types"
import { NOSE_ICON, NOSE_BIN, NOSE_WWW } from "./config" import { NOSE_ICON, NOSE_BIN, NOSE_WWW } from "./config"
import { transpile, isFile, tilde } from "./utils" import { transpile, isFile, tilde } from "./utils"
import { apps, serveApp, publishDNS } from "./webapp" import { apps, serveApp } from "./webapp"
import { initDNS } from "./dns"
import { commands } from "./commands" import { commands } from "./commands"
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket" import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
import { Layout } from "./components/layout" import { Layout } from "./html/layout"
import { Terminal } from "./components/terminal" import { Terminal } from "./html/terminal"
import { dispatchMessage } from "./dispatch" import { dispatchMessage } from "./dispatch"
import "./sneaker" import "./sneaker"
@ -136,7 +140,7 @@ if (process.env.BUN_HOT) {
}) })
} }
} else { } else {
publishDNS() initDNS()
} }

18
app/src/session.ts Normal file
View File

@ -0,0 +1,18 @@
////
// Session storage. 1 browser tab = 1 session
import { AsyncLocalStorage } from "async_hooks"
export type Session = {
taskId?: string
sessionId?: string
project?: string
}
// Ensure "ALS" lives between bun's hot reloads
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<Session> }
export const ALS = g.__thread ??= new AsyncLocalStorage<Session>()
export function getState(): Session | undefined {
return ALS.getStore()
}

View File

@ -4,6 +4,6 @@ export function countChar(str: string, char: string): number {
} }
// Generate a 6 character random ID // Generate a 6 character random ID
export function randomID(): string { export function randomId(): string {
return Math.random().toString(36).slice(7) return Math.random().toString(36).slice(7)
} }

View File

@ -1,14 +1,15 @@
//// ////
// runs commands and such. // Runs commands and such on the server.
// This is the "shell" - the "terminal" is the browser UI.
import { join } from "path" import { join } from "path"
import type { CommandResult, CommandOutput } from "./shared/types" import type { CommandResult, CommandOutput } from "./shared/types"
import type { State } from "./state" import type { Session } from "./session"
import { NOSE_SYS_BIN, NOSE_BIN } from "./config" import { NOSE_SYS_BIN, NOSE_BIN } from "./config"
import { isFile } from "./utils" import { isFile } from "./utils"
import { ALS } from "./state" import { ALS } from "./session"
const sessions: Map<string, State> = new Map() const sessions: Map<string, Session> = new Map()
export async function runCommand(session: string, id: string, input: string): Promise<CommandResult> { export async function runCommand(session: string, id: string, input: string): Promise<CommandResult> {
const [cmd = "", ...args] = input.split(" ") const [cmd = "", ...args] = input.split(" ")
@ -56,13 +57,13 @@ function processExecOutput(output: string | any): ["ok" | "error", CommandOutput
} }
} }
function getState(session: string, id: string): State { function getState(sessionId: string, taskId: string): Session {
let state = sessions.get(session) let state = sessions.get(sessionId)
if (!state) { if (!state) {
state = { session, project: "" } state = { sessionId: sessionId, project: "" }
sessions.set(session, state) sessions.set(sessionId, state)
} }
state.id = id state.taskId = taskId
return state return state
} }

View File

@ -1,3 +1,7 @@
////
// Sneaker is our tunneling service that allows you to share your local NOSE webapps
// with the public internet. It requires a sneaker server, usually hosted by us.
import nose from "./server" import nose from "./server"
const SNEAKER_URL = "nose.space" const SNEAKER_URL = "nose.space"

View File

@ -1,15 +0,0 @@
import { AsyncLocalStorage } from "async_hooks"
export type State = {
id?: string
session?: string
project?: string
}
// Ensure "ALS" lives between bun's hot reloads
const g = globalThis as typeof globalThis & { __thread?: AsyncLocalStorage<State> }
export const ALS = g.__thread ??= new AsyncLocalStorage<State>()
export function getState(): State | undefined {
return ALS.getStore()
}

View File

@ -1,8 +1,10 @@
import { Hono } from "hono" ////
// Shell utilities and helper functions.
import { statSync } from "fs" import { statSync } from "fs"
import { basename } from "path" import { basename } from "path"
import { stat } from "node:fs/promises" import { stat } from "node:fs/promises"
import { type Handler, toResponse } from "./webapp"
import { NOSE_ICON } from "./config" import { NOSE_ICON } from "./config"
// End the process with an instructive error if a directory doesn't exist. // End the process with an instructive error if a directory doesn't exist.
@ -63,17 +65,11 @@ export async function isBinaryFile(path: string): Promise<boolean> {
} }
} }
// Convert /Users/$USER or /home/$USER to ~ for simplicity // Convert /Users/$USER or /home/$USER to ~ for simplicity
export function tilde(path: string): string { export function tilde(path: string): string {
return path.replace(new RegExp(`/(Users|home)/${process.env.USER}`), "~") return path.replace(new RegExp(`/(Users|home)/${process.env.USER}`), "~")
} }
// Generate a random 8 character string
export function randomID(): string {
return Math.random().toString(36).slice(2, 10)
}
const transpiler = new Bun.Transpiler({ loader: 'tsx' }) const transpiler = new Bun.Transpiler({ loader: 'tsx' })
const transpileCache: Record<string, string> = {} const transpileCache: Record<string, string> = {}
@ -92,45 +88,3 @@ export async function transpile(path: string): Promise<string> {
return cached return cached
} }
//
// webapp utils (for writing webapps)
//
export function css(strings: TemplateStringsArray, ...values: any[]) {
return <style dangerouslySetInnerHTML={{
__html: strings.reduce((result, str, i) => {
return result + str + (values[i] || '')
}, '')
}} />
}
export function js(strings: TemplateStringsArray, ...values: any[]) {
return <script dangerouslySetInnerHTML={{
__html: strings.reduce((result, str, i) => {
return transpiler.transformSync(result + str + (values[i] || ''))
}, '')
}} />
}
// for defining routes in your NOSE webapp
// example:
// export default routes({
// "GET /": index,
// "GET /pets": pets
// })
export function routes(def: Record<string, Handler>): Hono {
const app = new Hono
for (const key in def) {
const parts = key.split(" ") // GET /path
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)))
}
return app
}

View File

@ -1,11 +1,14 @@
////
// Hosting for your NOSE webapps!
import type { Child } from "hono/jsx" import type { Child } from "hono/jsx"
import { type Context, Hono } from "hono" import { type Context, Hono } from "hono"
import { renderToString } from "hono/jsx/dom/server" import { renderToString } from "hono/jsx/dom/server"
import { join, dirname } from "path" import { join, dirname } from "path"
import { readdirSync, watch } from "fs" import { readdirSync } from "fs"
import { NOSE_WWW } from "./config" import { NOSE_WWW } from "./config"
import { expectDir, isFile } from "./utils" import { isFile } from "./utils"
export type Handler = (r: Context) => string | Child | Response | Promise<Response> export type Handler = (r: Context) => string | Child | Response | Promise<Response>
export type App = Hono | Handler export type App = Hono | Handler
@ -84,50 +87,3 @@ export function toResponse(source: string | Child | Response): Response {
} }
}) })
} }
//
// dns nonsense
//
const dnsEntries: Record<string, any> = {}
const { stdout: ipRaw } = await Bun.$`hostname -I | awk '{print $1}'`.quiet()
const { stdout: hostRaw } = await Bun.$`hostname`.quiet()
const ip = ipRaw.toString().trim()
const host = hostRaw.toString().trim()
export async function publishDNS() {
apps().forEach(publishAppDNS)
const signals = ["SIGINT", "SIGTERM"]
signals.forEach(sig =>
process.on(sig, () => {
for (const name in dnsEntries)
dnsEntries[name].kill("SIGTERM")
process.exit(0)
})
)
}
function publishAppDNS(app: string) {
if (process.env.NODE_ENV !== "production") return
if (!dnsEntries[app])
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])
return dnsEntries[app]
}
// exit process with error if no WWW dir
expectDir(NOSE_WWW)
const wwwWatcher = watch(NOSE_WWW, (event, filename) => {
const www = apps()
www.forEach(publishAppDNS)
for (const name in dnsEntries)
if (!www.includes(name)) {
dnsEntries[name].kill("SIGTERM")
delete dnsEntries[name]
}
})

View File

@ -22,7 +22,7 @@
"@types/bun": "latest" "@types/bun": "latest"
}, },
"alias": { "alias": {
"@utils": "./app/src/utils.tsx", "@nose": "./app/src/helpers.tsx",
"@config": "./app/src/config.ts", "@config": "./app/src/config.ts",
"@/*": "./app/src/*" "@/*": "./app/src/*"
} }