Compare commits

..

10 Commits

Author SHA1 Message Date
38d3481f8d prime time 2025-09-24 17:59:17 -07:00
5570322efa /tunnels and /tunnel/app 2025-09-24 09:54:00 -07:00
aa26b3fce5 note 2025-09-24 09:36:50 -07:00
40260ce958 update instructions 2025-09-24 09:34:03 -07:00
61d917ba7f NODE_ENV 2025-09-24 09:33:46 -07:00
0b6ff7f854 disconnect 2025-09-23 22:00:15 -07:00
a01e45b2b0 unshare 2025-09-23 22:00:10 -07:00
dff4831a81 expand "share" command 2025-09-23 21:43:52 -07:00
bdd320eab1 subdomains 2025-09-23 21:16:52 -07:00
cfa8f2795e share command 2025-09-23 21:03:10 -07:00
8 changed files with 156 additions and 41 deletions

View File

@ -9,16 +9,15 @@
## Local Dev ## Local Dev
bun install bun install
mkdir -p ~/nose/{bin,www}
bun dev bun dev
## Commands ## Commands
Commands can return one of three types: Commands can return one of three types:
- string - `string`
- { error: string } - `{ error: string }`
- { html: string } - `{ html: string }`
They can also `throw` to display an error. They can also `throw` to display an error.

View File

@ -1,7 +1,7 @@
import { $ } from "bun" import { $ } from "bun"
import { apps } from "app/src/webapp" import { apps } from "app/src/webapp"
const devMode = process.env.BUN_HOT const devMode = process.env.NODE_ENV !== "production"
export default async function () { export default async function () {
const { stdout: hostname } = await $`hostname`.quiet() const { stdout: hostname } = await $`hostname`.quiet()

23
app/nose/bin/share.ts Normal file
View File

@ -0,0 +1,23 @@
import { apps } from "app/src/webapp"
import { connectSneaker, sneakers, sneakerUrl } from "app/src/sneaker"
export default async function (app: string, subdomain = "") {
if (!app) {
let out = `usage: share <app> [subdomain]`
const apps = sneakers()
if (apps.length) {
out += "\n\nsharing\n" + apps.map(app => {
const url = sneakerUrl(app)
return `${app}: <a href="${url}">${url}</a>`
}).join("\n")
}
return { html: out }
}
if (!apps().includes(app)) {
return { error: `${app} not found` }
}
const url = sneakerUrl(await connectSneaker(app, subdomain))
return { html: `<a href="${url}">${url}</a>` }
}

18
app/nose/bin/unshare.ts Normal file
View File

@ -0,0 +1,18 @@
import { apps } from "app/src/webapp"
import { disconnectSneaker, sneakers } from "app/src/sneaker"
export default async function (app: string) {
if (!app) {
return "usage: unshare <app>"
}
if (!apps().includes(app)) {
return { error: `${app} not found` }
}
if (!sneakers().includes(app)) {
return { error: `${app} not shared` }
}
return (await disconnectSneaker(app)) ? "unshared" : `${app} wasn't shared`
}

View File

@ -5,6 +5,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "bun src/server.tsx", "start": "bun src/server.tsx",
"prod": "env NODE_ENV=production bun src/server.tsx",
"dev": "env BUN_HOT=1 bun --hot src/server.tsx", "dev": "env BUN_HOT=1 bun --hot src/server.tsx",
"deploy": "./scripts/deploy.sh", "deploy": "./scripts/deploy.sh",
"push": "./scripts/deploy.sh", "push": "./scripts/deploy.sh",

View File

@ -1,4 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 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}" HOST="${HOST:-chris@nose-pluto.local}"
DEST="${DEST:-~/pluto}" DEST="${DEST:-~/pluto}"

View File

@ -1,38 +1,110 @@
import app from "./server" import nose from "./server"
const SNEAKER_URL = "localhost:3100" const SNEAKER_URL = "nose.space"
const SNEAKER_TLS = true
// const SNEAKER_URL = "localhost:3100"
// const SNEAKER_TLS = false
const ws = new WebSocket(`ws://${SNEAKER_URL}/tunnel`) type Connection = {
subdomain: string
ws.onerror = e => console.log("sneaker error", e) ws: any
close: boolean // manual close
ws.onmessage = async event => {
const msg = JSON.parse(event.data.toString())
try {
const req = new Request("http://localhost" + msg.path, {
method: msg.method,
headers: msg.headers,
body: msg.body || undefined,
})
const res = await app.fetch(req)
const body = await res.text()
const headers: Record<string, string> = {}
res.headers.forEach((v, k) => (headers[k] = v))
ws.send(JSON.stringify({
id: msg.id,
status: res.status,
headers,
body,
}))
} catch (err: any) {
ws.send(JSON.stringify({
id: msg.id,
status: 500,
headers: { "content-type": "text/plain" },
body: "error: " + err.message,
}))
}
} }
const conns: Record<string, Connection> = {}
export function sneakerUrl(appOrSubdomain: string): string {
let conn = conns[appOrSubdomain]
if (!conn) {
for (const appName of Object.keys(conns)) {
if (conns[appName]?.subdomain === appOrSubdomain) {
conn = conns[appName]
break
}
}
if (!conn)
return "none"
}
let url = "http" + (SNEAKER_TLS ? "s" : "") + "://"
url += conn.subdomain + "." + SNEAKER_URL
return url
}
export function sneakers(): string[] {
return Object.keys(conns)
}
export function disconnectSneaker(app: string): boolean {
if (!sneakers().includes(app) || !conns[app])
return false
conns[app].close = true
conns[app].ws.close()
return true
}
// returns the sneaker subdomain if successful
export async function connectSneaker(app: string, subdomain = ""): Promise<string> {
if (conns[app]) {
return conns[app].subdomain
}
let url = `ws${SNEAKER_TLS ? "s" : ""}://${SNEAKER_URL}/tunnel?app=${app}`
if (subdomain) url += `&subdomain=${subdomain}`
const ws = new WebSocket(url)
let resolve: (v: string) => void
let promise = new Promise<string>(res => resolve = res)
ws.onclose = e => {
if (!conns[app]?.close)
setTimeout(() => connectSneaker(app, subdomain), 1000) // simple retry
delete conns[app]
}
ws.onerror = e => console.error("sneaker error", e)
ws.onmessage = async event => {
const msg = JSON.parse(event.data.toString())
if (msg.subdomain) {
conns[app] = { subdomain: msg.subdomain, ws, close: false }
subdomain = msg.subdomain
resolve(msg.subdomain)
return
}
try {
const req = new Request(`http://${msg.app}.localhost` + msg.path, {
method: msg.method,
headers: msg.headers,
body: msg.body || undefined,
})
const res = await nose.fetch(req)
const body = await res.text()
const headers: Record<string, string> = {}
res.headers.forEach((v, k) => (headers[k] = v))
ws.send(JSON.stringify({
id: msg.id,
status: res.status,
headers,
body,
}))
} catch (err: any) {
ws.send(JSON.stringify({
id: msg.id,
status: 500,
headers: { "content-type": "text/plain" },
body: "error: " + err.message,
}))
}
}
return promise
}

View File

@ -111,7 +111,7 @@ export async function publishDNS() {
} }
function publishAppDNS(app: string) { function publishAppDNS(app: string) {
if (process.env.BUN_HOT) return if (process.env.NODE_ENV !== "production") return
if (!dnsEntries[app]) if (!dnsEntries[app])
dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip]) dnsEntries[app] = Bun.spawn(["avahi-publish", "-a", `${app}.${host}.local`, "-R", ip])