From d1e2e7d7a4ffa1a75f561a5791b58a6c5cb871c2 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 29 Sep 2025 20:43:13 -0700 Subject: [PATCH] persist app sharing --- .gitignore | 1 + app/nose/bin/share.ts | 5 +++-- app/nose/bin/state.ts | 6 ++++++ app/src/config.ts | 2 ++ app/src/mutex.ts | 27 +++++++++++++++++++++++++++ app/src/server.tsx | 19 ++++++++++++++----- app/src/shell.ts | 2 +- app/src/sneaker.ts | 29 +++++++++++++++++++++++++++-- app/src/state.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 app/nose/bin/state.ts create mode 100644 app/src/mutex.ts create mode 100644 app/src/state.ts diff --git a/.gitignore b/.gitignore index a14702c..ca1c303 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +data/ # dependencies (bun install) node_modules diff --git a/app/nose/bin/share.ts b/app/nose/bin/share.ts index b99235f..e4b572f 100644 --- a/app/nose/bin/share.ts +++ b/app/nose/bin/share.ts @@ -3,11 +3,12 @@ import { apps } from "app/src/webapp" import { connectSneaker, sneakers, sneakerUrl } from "app/src/sneaker" -export default async function (app: string, subdomain = "") { +export default async function (app: string) { if (!app) { let out = `usage: share <app> [subdomain]` const apps = sneakers() if (apps.length) { + out += "\n\nUse `unshare` to stop sharing an app." out += "\n\nsharing\n" + apps.map(app => { const url = sneakerUrl(app) return `${app}: ${url}` @@ -20,6 +21,6 @@ export default async function (app: string, subdomain = "") { return { error: `${app} not found` } } - const url = sneakerUrl(await connectSneaker(app, subdomain)) + const url = sneakerUrl(await connectSneaker(app)) return { html: `${url}` } } \ No newline at end of file diff --git a/app/nose/bin/state.ts b/app/nose/bin/state.ts new file mode 100644 index 0000000..fe08919 --- /dev/null +++ b/app/nose/bin/state.ts @@ -0,0 +1,6 @@ +import { NOSE_DATA } from "@/config" +import { join } from "path" + +export default async function () { + return JSON.parse(await Bun.file(join(NOSE_DATA, "state.json")).text()) +} \ No newline at end of file diff --git a/app/src/config.ts b/app/src/config.ts index 6f0b58f..60ba536 100644 --- a/app/src/config.ts +++ b/app/src/config.ts @@ -10,5 +10,7 @@ export const NOSE_DIR = resolve("..") export const NOSE_BIN = join(NOSE_DIR, "bin") export const NOSE_WWW = join(NOSE_DIR, "www") +export const NOSE_DATA = resolve("./data") + export const NOSE_STARTED = Date.now() export const GIT_SHA = (await $`git rev-parse HEAD`.text()).trim() \ No newline at end of file diff --git a/app/src/mutex.ts b/app/src/mutex.ts new file mode 100644 index 0000000..4269b2c --- /dev/null +++ b/app/src/mutex.ts @@ -0,0 +1,27 @@ +//// +// Simple mutex for concurrent file writes + +export class Mutex { + private queue: (() => void)[] = [] + private locked = false + + async lock(): Promise<() => void> { + return new Promise(resolve => { + const unlock = () => { + const next = this.queue.shift() + if (next) { + next() + } else { + this.locked = false + } + } + + if (this.locked) { + this.queue.push(() => resolve(unlock)) + } else { + this.locked = true + resolve(unlock) + } + }) + } +} diff --git a/app/src/server.tsx b/app/src/server.tsx index 6cd8ba3..abe8e0a 100644 --- a/app/src/server.tsx +++ b/app/src/server.tsx @@ -7,17 +7,17 @@ import { prettyJSON } from "hono/pretty-json" import color from "kleur" import type { Message } from "./shared/types" -import { NOSE_ICON, NOSE_BIN, NOSE_WWW } from "./config" +import { NOSE_ICON, NOSE_BIN, NOSE_WWW, NOSE_DATA } from "./config" import { transpile, isFile, tilde } from "./utils" import { serveApp } from "./webapp" import { initDNS } from "./dns" -import { commands, commandSource, commandPath } from "./commands" +import { commands, commandPath } from "./commands" import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket" import { Layout } from "./html/layout" import { Terminal } from "./html/terminal" import { dispatchMessage } from "./dispatch" -import "./sneaker" +import { initSneakers, disconnectSneakers } from "./sneaker" // // Hono setup @@ -129,6 +129,7 @@ if (process.env.BUN_HOT) { // @ts-ignore globalThis.__hot_reload_cleanup = () => { closeWebsockets() + disconnectSneakers() } for (const sig of ["SIGINT", "SIGTERM"] as const) { @@ -138,19 +139,27 @@ if (process.env.BUN_HOT) { process.exit(0) }) } -} else { - initDNS() } +// +// production mode +// + +if (process.env.NODE_ENV === "production") { + initDNS() +} // // server start // console.log(color.cyan(NOSE_ICON)) +console.log(color.blue("NOSE_DATA:"), color.yellow(tilde(NOSE_DATA))) console.log(color.blue("NOSE_BIN:"), color.yellow(tilde(NOSE_BIN))) console.log(color.blue("NOSE_WWW:"), color.yellow(tilde(NOSE_WWW))) +initSneakers() + export default { port: process.env.PORT || 3000, hostname: "0.0.0.0", diff --git a/app/src/shell.ts b/app/src/shell.ts index 1f6bb05..97c550c 100644 --- a/app/src/shell.ts +++ b/app/src/shell.ts @@ -83,5 +83,5 @@ function errorMessage(error: Error | any): string { } function isJSX(obj: any): boolean { - return 'tag' in obj && 'props' in obj && 'children' in obj + return typeof obj === 'object' && 'tag' in obj && 'props' in obj && 'children' in obj } \ No newline at end of file diff --git a/app/src/sneaker.ts b/app/src/sneaker.ts index c617425..4b7557c 100644 --- a/app/src/sneaker.ts +++ b/app/src/sneaker.ts @@ -3,12 +3,15 @@ // 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 @@ -16,6 +19,19 @@ type Connection = { } const conns: Record = {} +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) { @@ -29,7 +45,6 @@ export function sneakerUrl(appOrSubdomain: string): string { return "none" } - let url = "http" + (SNEAKER_TLS ? "s" : "") + "://" url += conn.subdomain + "." + SNEAKER_URL @@ -40,10 +55,19 @@ export function sneakers(): string[] { return Object.keys(conns) } -export function disconnectSneaker(app: string): boolean { +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 { if (!sneakers().includes(app) || !conns[app]) return false + await clearState(`${PREFIX}${app}`) conns[app].close = true conns[app].ws.close() @@ -76,6 +100,7 @@ export async function connectSneaker(app: string, subdomain = ""): Promise { + try { + return JSON.parse(readFileSync(statePath, 'utf8')) + } catch (e: any) { + if (e.code === "ENOENT") return {} + throw e + } +}