Compare commits
No commits in common. "6361316f14477c84b59c1ce28f763bb3ca04002f" and "0b2f73eafa7553759a23644d701425df41726929" have entirely different histories.
6361316f14
...
0b2f73eafa
|
|
@ -1,7 +1,7 @@
|
|||
// Show the webapps hosted on this NOSEputer.
|
||||
|
||||
import { $ } from "bun"
|
||||
import { apps } from "@/webapp/utils"
|
||||
import { apps } from "@/webapp"
|
||||
|
||||
const devMode = process.env.NODE_ENV !== "production"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Share a webapp with the public internet.
|
||||
|
||||
import { apps } from "@/webapp/utils"
|
||||
import { apps } from "@/webapp"
|
||||
import { connectSneaker, sneakers, sneakerUrl } from "@/sneaker"
|
||||
|
||||
export default async function (app: string) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Stop sharing a webapp with the public internet.
|
||||
|
||||
import { apps } from "@/webapp/utils"
|
||||
import { apps } from "@/webapp"
|
||||
import { disconnectSneaker, sneakers } from "@/sneaker"
|
||||
|
||||
export default async function (app: string) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Publishes webapps as subdomains on your local network
|
||||
|
||||
import { watch } from "fs"
|
||||
import { apps } from "./webapp/utils"
|
||||
import { apps } from "./webapp"
|
||||
import { expectDir } from "./utils"
|
||||
import { NOSE_DIR } from "./config"
|
||||
import { expectShellCmd } from "./utils"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,15 @@
|
|||
// import { css } from "@nose"
|
||||
|
||||
import { Hono } from "hono"
|
||||
import { type Handler } from "./webapp/server"
|
||||
import { toResponse } from "./webapp/utils"
|
||||
import { type Handler, toResponse } from "./webapp"
|
||||
|
||||
//
|
||||
// command helpers
|
||||
//
|
||||
|
||||
// (none for now)
|
||||
|
||||
|
||||
|
||||
//
|
||||
// webapp helpers
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ import color from "kleur"
|
|||
|
||||
import type { Message } from "./shared/types"
|
||||
import { rewriteJsImports } from "./build"
|
||||
import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN, BUN_BIN, DEFAULT_PROJECT } from "./config"
|
||||
import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN, DEFAULT_PROJECT } from "./config"
|
||||
import { transpile, isFile, tilde, isDir } from "./utils"
|
||||
import { serveApp, initWebapps } from "./webapp/server"
|
||||
import { apps } from "./webapp/utils"
|
||||
import { serveApp, apps, initWebapps } from "./webapp"
|
||||
import { commands, commandPath, loadCommandModule } from "./commands"
|
||||
import { runCommandFn } from "./shell"
|
||||
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
|
||||
|
|
@ -238,11 +237,10 @@ if (process.env.NODE_ENV === "production") {
|
|||
|
||||
await initNoseDir()
|
||||
initCommands()
|
||||
await initWebapps()
|
||||
initWebapps()
|
||||
initSneakers()
|
||||
|
||||
console.log(color.cyan(NOSE_ICON))
|
||||
console.log(color.blue(" BUN_BIN:"), color.yellow(tilde(BUN_BIN)))
|
||||
console.log(color.blue(" NOSE_BIN:"), color.yellow(tilde(NOSE_BIN)))
|
||||
console.log(color.blue(" NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
|
||||
console.log(color.blue(" NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
|
||||
|
|
|
|||
113
src/webapp.ts
Normal file
113
src/webapp.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
////
|
||||
// Hosting for your NOSE webapps!
|
||||
|
||||
import type { Child } from "hono/jsx"
|
||||
import { type Context, Hono } from "hono"
|
||||
import { join } from "path"
|
||||
import { readdirSync, watch } from "fs"
|
||||
import { sendAll } from "./websocket"
|
||||
import { expectDir } from "./utils"
|
||||
import { NOSE_DIR } from "./config"
|
||||
import { isFile, isDir, mtimeEpoch } from "./utils"
|
||||
import { importUrl } from "./shared/utils"
|
||||
|
||||
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
|
||||
export type App = Hono | Handler
|
||||
|
||||
export function initWebapps() {
|
||||
startWatcher()
|
||||
}
|
||||
|
||||
export async function serveApp(c: Context, subdomain: string): Promise<Response> {
|
||||
const app = await findApp(subdomain)
|
||||
const path = appDir(subdomain)
|
||||
|
||||
if (!path) return c.text(`App not found: ${subdomain}`, 404)
|
||||
|
||||
const staticPath = join(path, "pub", c.req.path === "/" ? "/index.html" : c.req.path)
|
||||
|
||||
if (isFile(staticPath))
|
||||
return serveStatic(staticPath)
|
||||
|
||||
if (!app) return c.text(`App not found: ${subdomain}`, 404)
|
||||
|
||||
if (app instanceof Hono)
|
||||
return app.fetch(c.req.raw)
|
||||
else
|
||||
return toResponse(await app(c))
|
||||
}
|
||||
|
||||
export function apps(): string[] {
|
||||
const apps: string[] = []
|
||||
|
||||
for (const entry of readdirSync(NOSE_DIR))
|
||||
if (isApp(entry))
|
||||
apps.push(entry)
|
||||
|
||||
return apps.sort()
|
||||
}
|
||||
|
||||
function isApp(name: string): boolean {
|
||||
return isFile(join(NOSE_DIR, name, "index.ts"))
|
||||
|| isFile(join(NOSE_DIR, name, "index.tsx"))
|
||||
|| isDir(join(NOSE_DIR, name, "pub"))
|
||||
}
|
||||
|
||||
export function appDir(name: string): string | undefined {
|
||||
if (isApp(name))
|
||||
return join(NOSE_DIR, name)
|
||||
}
|
||||
|
||||
async function findApp(name: string): Promise<App | undefined> {
|
||||
const paths = [
|
||||
join(name, "index.ts"),
|
||||
join(name, "index.tsx")
|
||||
]
|
||||
|
||||
for (const path of paths) {
|
||||
const app = await loadApp(join(NOSE_DIR, path))
|
||||
if (app) return app
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApp(path: string): Promise<App | undefined> {
|
||||
if (!await Bun.file(path).exists()) return
|
||||
|
||||
const mod = await importUrl(path, await mtimeEpoch(path))
|
||||
if (mod?.default)
|
||||
return mod.default as App
|
||||
}
|
||||
|
||||
export async function toResponse(source: string | Child | Response): Promise<Response> {
|
||||
if (source instanceof Response)
|
||||
return source
|
||||
else if (typeof source === "string")
|
||||
return new Response(source)
|
||||
else
|
||||
return new Response(await source?.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function serveStatic(path: string): Response {
|
||||
const file = Bun.file(path)
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": file.type
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let wwwWatcher
|
||||
function startWatcher() {
|
||||
if (!expectDir(NOSE_DIR)) return
|
||||
|
||||
wwwWatcher = watch(NOSE_DIR, { recursive: true }, async (event, filename) => {
|
||||
if (!filename) return
|
||||
|
||||
if (/^.+\/index\.tsx?$/.test(filename))
|
||||
sendAll({ type: "apps", data: apps() })
|
||||
})
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
////
|
||||
// Hosting for your NOSE webapps!
|
||||
|
||||
import type { Child } from "hono/jsx"
|
||||
import { type Context, Hono } from "hono"
|
||||
import { join } from "path"
|
||||
import { watch } from "fs"
|
||||
import { sendAll } from "../websocket"
|
||||
import { expectDir } from "../utils"
|
||||
import { NOSE_DIR, BUN_BIN } from "../config"
|
||||
import { isFile } from "../utils"
|
||||
import { apps, isApp, appDir, isStaticApp } from "./utils"
|
||||
|
||||
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
|
||||
export type App = Hono | Handler
|
||||
|
||||
const processes = new Map<string, { port: string, proc: ReturnType<typeof Bun.spawn> }>()
|
||||
const restarting = new Set<string>()
|
||||
|
||||
export async function initWebapps() {
|
||||
await startSubprocs()
|
||||
startWatcher()
|
||||
}
|
||||
|
||||
export async function serveApp(c: Context, subdomain: string): Promise<Response> {
|
||||
if (!isApp(subdomain)) return c.text(`App not found: ${subdomain}`, 404)
|
||||
|
||||
const staticPath = join(appDir(subdomain)!, "pub", c.req.path === "/" ? "/index.html" : c.req.path)
|
||||
if (isFile(staticPath)) return serveStatic(staticPath)
|
||||
|
||||
if (isStaticApp(subdomain)) return c.text("File not found", 404)
|
||||
|
||||
const port = await startApp(subdomain)
|
||||
if (!port) return c.text(`App not found: ${subdomain}`, 404)
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}${c.req.path}`, {
|
||||
method: c.req.method,
|
||||
headers: c.req.raw.headers,
|
||||
body: c.req.raw.body,
|
||||
})
|
||||
|
||||
return new Response(res.body, { status: res.status, headers: res.headers })
|
||||
} catch {
|
||||
return c.text("File not found", 404)
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp(name: string): Promise<string | undefined> {
|
||||
if (isStaticApp(name)) return
|
||||
|
||||
const existing = processes.get(name)
|
||||
if (existing) return existing.port
|
||||
|
||||
const port = String(4000 + processes.size)
|
||||
const proc = Bun.spawn({
|
||||
cmd: [BUN_BIN, "run", "src/webapp/worker.ts", name],
|
||||
env: { PORT: port },
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
processes.set(name, { port, proc })
|
||||
proc.exited.then(() => restartApp(name))
|
||||
|
||||
await Bun.sleep(100) // give it time before first request
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
|
||||
function serveStatic(path: string): Response {
|
||||
const file = Bun.file(path)
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": file.type
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
for (const [name, { port, proc }] of processes) {
|
||||
console.log(`Shutting down ${name}`)
|
||||
try { proc.kill() } catch { }
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
async function restartApp(name: string) {
|
||||
if (restarting.has(name)) return
|
||||
restarting.add(name)
|
||||
|
||||
console.log(`[child:${name}]`, "restarting")
|
||||
const existing = processes.get(name)
|
||||
if (existing) {
|
||||
try { existing.proc.kill() } catch { }
|
||||
processes.delete(name)
|
||||
}
|
||||
|
||||
await Bun.sleep(200) // give bun time to release the port
|
||||
restarting.delete(name)
|
||||
await startApp(name)
|
||||
}
|
||||
|
||||
async function startSubprocs() {
|
||||
const list = apps()
|
||||
await Promise.all(list.map(app => startApp(app)))
|
||||
}
|
||||
|
||||
|
||||
let wwwWatcher
|
||||
function startWatcher() {
|
||||
if (!expectDir(NOSE_DIR)) return
|
||||
|
||||
wwwWatcher = watch(NOSE_DIR, { recursive: true }, async (event, filename) => {
|
||||
if (!filename) return
|
||||
|
||||
const [appName,] = filename.split("/")
|
||||
if (appName && !filename.includes("/pub/"))
|
||||
restartApp(appName)
|
||||
|
||||
if (/^.+\/index\.tsx?$/.test(filename))
|
||||
sendAll({ type: "apps", data: apps() })
|
||||
})
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", shutdown)
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { join } from "path"
|
||||
import { readdirSync } from "fs"
|
||||
import type { Child } from "hono/jsx"
|
||||
import { NOSE_DIR } from "../config"
|
||||
import { isFile, isDir } from "../utils"
|
||||
|
||||
export async function appPath(appName: string): Promise<string | undefined> {
|
||||
const files = [join(NOSE_DIR, appName, "index.ts"), join(NOSE_DIR, appName, "index.tsx")]
|
||||
|
||||
for (const file of files)
|
||||
if (await Bun.file(file).exists())
|
||||
return file
|
||||
}
|
||||
|
||||
export async function toResponse(source: string | Child | Response): Promise<Response> {
|
||||
if (source instanceof Response)
|
||||
return source
|
||||
else if (typeof source === "string")
|
||||
return new Response(source)
|
||||
else
|
||||
return new Response(await source?.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function apps(): string[] {
|
||||
const apps: string[] = []
|
||||
|
||||
for (const entry of readdirSync(NOSE_DIR))
|
||||
if (isApp(entry))
|
||||
apps.push(entry)
|
||||
|
||||
return apps.sort()
|
||||
}
|
||||
|
||||
export function isApp(name: string): boolean {
|
||||
return isFile(join(NOSE_DIR, name, "index.ts"))
|
||||
|| isFile(join(NOSE_DIR, name, "index.tsx"))
|
||||
|| isDir(join(NOSE_DIR, name, "pub"))
|
||||
}
|
||||
|
||||
export function isStaticApp(name: string): boolean {
|
||||
return !isFile(join(NOSE_DIR, name, "index.ts"))
|
||||
&& !isFile(join(NOSE_DIR, name, "index.tsx"))
|
||||
&& isDir(join(NOSE_DIR, name, "pub"))
|
||||
}
|
||||
|
||||
export function appDir(name: string): string | undefined {
|
||||
if (isApp(name))
|
||||
return join(NOSE_DIR, name)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
////
|
||||
// This is the child process that runs a single webapp.
|
||||
|
||||
import { Hono } from "hono"
|
||||
import { appPath, toResponse } from "./utils"
|
||||
|
||||
const appName = Bun.argv[2]
|
||||
if (!appName) {
|
||||
console.log("usage: bun run ./src/webapp/worker.ts <app-name>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const path = await appPath(appName)
|
||||
if (!path) throw `Can't find app: ${appName}`
|
||||
|
||||
const mod = await import(path)
|
||||
|
||||
const handler = mod.default
|
||||
if (typeof handler !== "function") throw `no default export in ${appName}`
|
||||
|
||||
const app = new Hono()
|
||||
app.all("*", async c => toResponse(await handler(c)))
|
||||
const port = Number(process.env.PORT || 4000)
|
||||
Bun.serve({ port, fetch: app.fetch })
|
||||
|
||||
console.log(`[child:${appName}] listening on localhost:${port}`)
|
||||
Loading…
Reference in New Issue
Block a user