nose-pluto/app/src/sneaker.ts

174 lines
4.8 KiB
TypeScript

////
// 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 { clearState, setState, getState } from "app/src/state"
const SNEAKER_URL = "nose.space"
const SNEAKER_TLS = true
// const SNEAKER_URL = "localhost:3100"
// const SNEAKER_TLS = false
const PREFIX = "sneaker:"
type Connection = {
subdomain: string
ws: any
close: boolean // manual close
}
const conns: Record<string, Connection> = {}
export function initSneakers() {
const state = getState()
if (!state) return
for (const key in state) {
if (key.startsWith(PREFIX)) {
const app = key.replace(PREFIX, "")
console.log("sharing", app, state[key])
connectSneaker(app, state[key])
}
}
}
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 async function disconnectSneakers() {
for (const app in conns) {
const conn = conns[app]!
conn.close = true
conn.ws.close()
}
}
export async function disconnectSneaker(app: string): Promise<boolean> {
if (!sneakers().includes(app) || !conns[app])
return false
await clearState(`${PREFIX}${app}`)
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)
ws.binaryType = 'arraybuffer'
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 }
await setState(`${PREFIX}${app}`, msg.subdomain)
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 headers: Record<string, string> = {}
res.headers.forEach((v, k) => (headers[k] = v))
const contentType = res.headers.get('content-type') || ''
const isBinary = isBinaryContentType(contentType)
let body: string | ArrayBuffer
let responseData: any
if (isBinary) {
const arrayBuffer = await res.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
responseData = {
id: msg.id,
status: res.status,
headers,
body: base64,
isBinary: true
}
} else {
body = await res.text()
responseData = {
id: msg.id,
status: res.status,
headers,
body,
isBinary: false
}
}
ws.send(JSON.stringify(responseData))
} catch (err: any) {
ws.send(JSON.stringify({
id: msg.id,
status: 500,
headers: { "content-type": "text/plain" },
body: "error: " + err.message,
isBinary: false
}))
}
}
return promise
}
function isBinaryContentType(contentType: string): boolean {
const binaryTypes = [
'image/', 'audio/', 'video/', 'application/octet-stream',
'application/pdf', 'application/zip', 'font/'
]
return binaryTypes.some(type => contentType.startsWith(type))
}