Compare commits
11 Commits
38d3481f8d
...
713754fece
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713754fece | ||
|
|
662fc42ff7 | ||
|
|
7534b65925 | ||
|
|
8efc808af2 | ||
|
|
12f4ce9657 | ||
|
|
7f695eb9eb | ||
|
|
acabf8c4c6 | ||
|
|
ef282e6df7 | ||
| bbff3324f3 | |||
| fc0a19c45d | |||
| 1010c6586a |
|
|
@ -1,15 +1,20 @@
|
|||
# NOSE:pluto
|
||||
|
||||
- [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
|
||||
## Installation
|
||||
|
||||
You just need to make a "nose" user on your RPi and make sure you can ssh in.
|
||||
|
||||
Then run `bun remote:install`.
|
||||
|
||||
When it's done (it'll reboot) visit:
|
||||
|
||||
http://nose-pluto.local
|
||||
|
||||
## Local Dev
|
||||
|
||||
bun install
|
||||
bun dev
|
||||
open localhost:3000
|
||||
|
||||
## 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:
|
||||
|
||||
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
|
||||
|
|
@ -4,7 +4,7 @@ import { join, extname } from "path"
|
|||
|
||||
import type { CommandOutput } from "app/src/shared/types"
|
||||
import { NOSE_WWW } from "app/src/config"
|
||||
import { getState } from "app/src/state"
|
||||
import { getState } from "@/session"
|
||||
import { appPath } from "app/src/webapp"
|
||||
import { isBinaryFile } from "app/src/utils"
|
||||
import { highlight } from "../lib/highlight"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join, extname } from "path"
|
|||
|
||||
import type { CommandOutput } from "app/src/shared/types"
|
||||
import { NOSE_WWW } from "app/src/config"
|
||||
import { getState } from "app/src/state"
|
||||
import { getState } from "@/session"
|
||||
import { appPath } from "app/src/webapp"
|
||||
import { isBinaryFile } from "app/src/utils"
|
||||
import { countChar } from "app/src/shared/utils"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apps } from "app/src/webapp"
|
||||
import { getState } from "app/src/state"
|
||||
import { getState } from "@/session"
|
||||
|
||||
export default function (project: string) {
|
||||
const state = getState()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { readdirSync } from "fs"
|
||||
import { NOSE_WWW } from "app/src/config"
|
||||
import { getState } from "app/src/state"
|
||||
import { getState } from "@/session"
|
||||
import { appPath } from "app/src/webapp"
|
||||
|
||||
export default function () {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getState } from "app/src/state"
|
||||
import { getState } from "@/session"
|
||||
|
||||
export default function () {
|
||||
const state = getState()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { apps } from "app/src/webapp"
|
||||
import { getState } from "app/src/state"
|
||||
import { getState } from "@/session"
|
||||
|
||||
export default function () {
|
||||
const state = getState()
|
||||
|
|
|
|||
17
app/nose/bin/update.ts
Normal file
17
app/nose/bin/update.ts
Normal 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..."
|
||||
}
|
||||
}
|
||||
|
|
@ -2,5 +2,6 @@
|
|||
|
||||
# It isn't enough to modify this yet.
|
||||
# You also need to manually update the nose-pluto.service file.
|
||||
HOST="${HOST:-chris@nose-pluto.local}"
|
||||
DEST="${DEST:-~/pluto}"
|
||||
HOST="${HOST:-nose@nose-pluto.local}"
|
||||
DEST="${DEST:-~/nose}"
|
||||
REPO="${REPO:-https://git.nose.space/defunkt/nose-pluto}"
|
||||
|
|
@ -1,39 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
##
|
||||
# deploys from your dev machine to your NOSEputer
|
||||
# Get absolute path of this script’s directory
|
||||
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
|
||||
SOCK="$HOME/.ssh/cm-%r@%h:%p"
|
||||
|
||||
# 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"
|
||||
# Run remote install on the target
|
||||
ssh $HOST "cd $DEST && git pull && bun install && sudo systemctl restart nose-pluto.service"
|
||||
|
|
@ -17,8 +17,11 @@ if [ ! -x "$BUN_SYMLINK" ]; then
|
|||
sudo ln -sf "$BUN_REAL" "$BUN_SYMLINK"
|
||||
echo "Symlinked $BUN_REAL -> $BUN_SYMLINK"
|
||||
else
|
||||
echo "Error: bun not found at $BUN_REAL"
|
||||
exit 1
|
||||
echo ">> Installing bun at $BUN_REAL"
|
||||
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
|
||||
else
|
||||
echo "bun already available at $BUN_SYMLINK"
|
||||
|
|
@ -42,5 +45,12 @@ sudo systemctl enable "$SERVICE_NAME"
|
|||
echo ">> Starting (or restarting) $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
|
||||
sudo reboot
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ After=network-online.target
|
|||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=chris
|
||||
WorkingDirectory=/home/chris/pluto/app
|
||||
User=nose
|
||||
WorkingDirectory=/home/nose/nose/app
|
||||
Environment=PORT=80
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/home/chris/.bun/bin/bun start
|
||||
ExecStart=/home/nose/.bun/bin/bun start
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
##
|
||||
# setup your NOSEputer from your dev machine
|
||||
# Get absolute path of this script’s directory
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,2 +1,9 @@
|
|||
#!/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"
|
||||
|
|
|
|||
|
|
@ -1,2 +1,9 @@
|
|||
#!/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"
|
||||
|
|
|
|||
|
|
@ -1,2 +1,9 @@
|
|||
#!/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"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
////
|
||||
// Manages the commands on disk, in NOSE_SYS_BIN and NOSE_BIN
|
||||
|
||||
import { Glob } from "bun"
|
||||
import { watch } from "fs"
|
||||
import { sendAll } from "./websocket"
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
////
|
||||
// Dispatch Messages
|
||||
// Dispatch Messages received via WebSocket
|
||||
|
||||
import { basename } from "path"
|
||||
import type { Message } from "./shared/types"
|
||||
import { runCommand } from "./shell"
|
||||
import { send } from "./websocket"
|
||||
import { isFile } from "./utils"
|
||||
|
||||
export async function dispatchMessage(ws: any, msg: Message) {
|
||||
if (msg.type === "input") {
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
await inputMessage(ws, msg); break
|
||||
|
||||
case "save-file":
|
||||
await saveFileMessage(ws, msg); break
|
||||
|
||||
default:
|
||||
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 })
|
||||
}
|
||||
|
||||
} else if (msg.type === "save-file") {
|
||||
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)}` } })
|
||||
}
|
||||
|
||||
} else {
|
||||
send(ws, { type: "error", data: `unknown message: ${msg.type}` })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
55
app/src/dns.ts
Normal file
55
app/src/dns.ts
Normal 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
67
app/src/helpers.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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.js"
|
||||
|
||||
export const sessionID = randomID()
|
||||
export const sessionID = randomId()
|
||||
|
|
@ -4,14 +4,14 @@
|
|||
import type { Message, CommandResult } from "../shared/types.js"
|
||||
import { addInput, setStatus, addOutput } from "./scrollback.js"
|
||||
import { send } from "./websocket.js"
|
||||
import { randomID } from "../shared/utils.js"
|
||||
import { randomId } from "../shared/utils.js"
|
||||
import { addToHistory } from "./history.js"
|
||||
import { browserCommands, cacheCommands } from "./commands.js"
|
||||
|
||||
export function runCommand(input: string) {
|
||||
if (!input.trim()) return
|
||||
|
||||
const id = randomID()
|
||||
const id = randomId()
|
||||
|
||||
addToHistory(input)
|
||||
addInput(id, input)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
////
|
||||
// Web server that serves shell commands, websocket connections, etc
|
||||
|
||||
import { Hono } from "hono"
|
||||
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
|
||||
import { prettyJSON } from "hono/pretty-json"
|
||||
|
|
@ -6,12 +9,13 @@ import color from "kleur"
|
|||
import type { Message } from "./shared/types"
|
||||
import { NOSE_ICON, NOSE_BIN, NOSE_WWW } from "./config"
|
||||
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 { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
|
||||
|
||||
import { Layout } from "./components/layout"
|
||||
import { Terminal } from "./components/terminal"
|
||||
import { Layout } from "./html/layout"
|
||||
import { Terminal } from "./html/terminal"
|
||||
import { dispatchMessage } from "./dispatch"
|
||||
import "./sneaker"
|
||||
|
||||
|
|
@ -136,7 +140,7 @@ if (process.env.BUN_HOT) {
|
|||
})
|
||||
}
|
||||
} else {
|
||||
publishDNS()
|
||||
initDNS()
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
18
app/src/session.ts
Normal file
18
app/src/session.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -4,6 +4,6 @@ export function countChar(str: string, char: string): number {
|
|||
}
|
||||
|
||||
// Generate a 6 character random ID
|
||||
export function randomID(): string {
|
||||
export function randomId(): string {
|
||||
return Math.random().toString(36).slice(7)
|
||||
}
|
||||
|
|
@ -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 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 { 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> {
|
||||
const [cmd = "", ...args] = input.split(" ")
|
||||
|
|
@ -56,13 +57,13 @@ function processExecOutput(output: string | any): ["ok" | "error", CommandOutput
|
|||
}
|
||||
}
|
||||
|
||||
function getState(session: string, id: string): State {
|
||||
let state = sessions.get(session)
|
||||
function getState(sessionId: string, taskId: string): Session {
|
||||
let state = sessions.get(sessionId)
|
||||
if (!state) {
|
||||
state = { session, project: "" }
|
||||
sessions.set(session, state)
|
||||
state = { sessionId: sessionId, project: "" }
|
||||
sessions.set(sessionId, state)
|
||||
}
|
||||
state.id = id
|
||||
state.taskId = taskId
|
||||
return state
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
const SNEAKER_URL = "nose.space"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { Hono } from "hono"
|
||||
////
|
||||
// Shell utilities and helper functions.
|
||||
|
||||
import { statSync } from "fs"
|
||||
import { basename } from "path"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { type Handler, toResponse } from "./webapp"
|
||||
|
||||
import { NOSE_ICON } from "./config"
|
||||
|
||||
// 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
|
||||
export function tilde(path: string): string {
|
||||
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 transpileCache: Record<string, string> = {}
|
||||
|
||||
|
|
@ -92,45 +88,3 @@ export async function transpile(path: string): Promise<string> {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
////
|
||||
// Hosting for your NOSE webapps!
|
||||
|
||||
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 { readdirSync, watch } from "fs"
|
||||
import { readdirSync } from "fs"
|
||||
|
||||
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 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]
|
||||
}
|
||||
})
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"@types/bun": "latest"
|
||||
},
|
||||
"alias": {
|
||||
"@utils": "./app/src/utils.tsx",
|
||||
"@nose": "./app/src/helpers.tsx",
|
||||
"@config": "./app/src/config.ts",
|
||||
"@/*": "./app/src/*"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user