173 lines
4.7 KiB
TypeScript
173 lines
4.7 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 "./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, "")
|
|
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))
|
|
}
|