Compare commits
3 Commits
090049c08c
...
3ec5b8d1e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec5b8d1e5 | ||
|
|
0b2f73eafa | ||
|
|
d7dab69405 |
|
|
@ -1,7 +1,7 @@
|
||||||
// Show the webapps hosted on this NOSEputer.
|
// Show the webapps hosted on this NOSEputer.
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { apps } from "@/webapp"
|
import { apps } from "@/webapp/server"
|
||||||
|
|
||||||
const devMode = process.env.NODE_ENV !== "production"
|
const devMode = process.env.NODE_ENV !== "production"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Share a webapp with the public internet.
|
// Share a webapp with the public internet.
|
||||||
|
|
||||||
import { apps } from "@/webapp"
|
import { apps } from "@/webapp/server"
|
||||||
import { connectSneaker, sneakers, sneakerUrl } from "@/sneaker"
|
import { connectSneaker, sneakers, sneakerUrl } from "@/sneaker"
|
||||||
|
|
||||||
export default async function (app: string) {
|
export default async function (app: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Stop sharing a webapp with the public internet.
|
// Stop sharing a webapp with the public internet.
|
||||||
|
|
||||||
import { apps } from "@/webapp"
|
import { apps } from "@/webapp/server"
|
||||||
import { disconnectSneaker, sneakers } from "@/sneaker"
|
import { disconnectSneaker, sneakers } from "@/sneaker"
|
||||||
|
|
||||||
export default async function (app: string) {
|
export default async function (app: string) {
|
||||||
|
|
|
||||||
62
nose/CLAUDE.md
Normal file
62
nose/CLAUDE.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is a NOSE_DIR (user directory for NOSE - a browser-based terminal and server-based shell written in TypeScript). It contains multiple projects that can be accessed as subdomains.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Each top-level directory represents a **project**. Projects are accessed via subdomains (e.g., `chris.nose-pluto.local` for the `chris` project).
|
||||||
|
|
||||||
|
### Project Anatomy
|
||||||
|
|
||||||
|
A project can contain:
|
||||||
|
|
||||||
|
- **`bin/`** - Commands executable from the NOSE terminal. Each `.ts` or `.tsx` file in `bin/` exports a default function that becomes a command.
|
||||||
|
- **`index.ts` or `index.tsx`** - Web application entry point. When present, the project serves as a webapp at its subdomain.
|
||||||
|
- **`pub/`** - Static files (HTML, CSS, images, etc.) served publicly by the web server.
|
||||||
|
- **`test/`** - Test files for the project.
|
||||||
|
|
||||||
|
### The Root Project
|
||||||
|
|
||||||
|
The **`root/`** project is special:
|
||||||
|
- Cannot be deleted
|
||||||
|
- Cannot host webapps or static files (no `index.ts/tsx` or `pub/` directory)
|
||||||
|
- Commands in `root/bin/` are globally available in all other projects
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Runtime**: Bun (TypeScript runtime)
|
||||||
|
- **Web Framework**: Hono (lightweight web framework)
|
||||||
|
- **JSX Runtime**: Hono JSX (`jsxImportSource: "hono/jsx"`)
|
||||||
|
- **Language**: TypeScript with strict mode
|
||||||
|
|
||||||
|
## Type System
|
||||||
|
|
||||||
|
- `global.d.ts` defines a global `Context` type that aliases `HonoContext` from Hono
|
||||||
|
- TSConfig uses path aliases pointing to central NOSE installation at `/home/nose/.nose/src/`:
|
||||||
|
- `@utils` → `/home/nose/.nose/src/utils.tsx`
|
||||||
|
- `@/*` → `/home/nose/.nose/src/*`
|
||||||
|
|
||||||
|
## Creating New Projects
|
||||||
|
|
||||||
|
To create a new project, use the `mkproject` command in your NOSE terminal.
|
||||||
|
|
||||||
|
## Web Application Patterns
|
||||||
|
|
||||||
|
Web apps export default functions that:
|
||||||
|
- Receive a Hono `Context` parameter (typed globally as `Context`)
|
||||||
|
- Return strings, JSX, or Response objects
|
||||||
|
- Can use Hono JSX for rendering HTML
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```tsx
|
||||||
|
export default (c: Context) =>
|
||||||
|
<html><body>Hello World</body></html>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Commands in `bin/` directories export default functions that return strings or JSX to be displayed in the NOSE terminal.
|
||||||
|
|
@ -13,5 +13,9 @@ export const NOSE_ROOT_BIN = join(NOSE_DIR, DEFAULT_PROJECT, "bin")
|
||||||
|
|
||||||
export const NOSE_STARTED = Date.now()
|
export const NOSE_STARTED = Date.now()
|
||||||
|
|
||||||
export const GIT_SHA = (await $`git rev-parse --short HEAD`.text()).trim()
|
let gitSHA = "<development>"
|
||||||
; (globalThis as any).GIT_SHA = GIT_SHA
|
try { gitSHA = (await $`git rev-parse --short HEAD`.text()).trim() } catch { }
|
||||||
|
export const GIT_SHA = gitSHA
|
||||||
|
; (globalThis as any).GIT_SHA = GIT_SHA
|
||||||
|
|
||||||
|
export const BUN_BIN = untilde("~/.bun/bin/bun")
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Publishes webapps as subdomains on your local network
|
// Publishes webapps as subdomains on your local network
|
||||||
|
|
||||||
import { watch } from "fs"
|
import { watch } from "fs"
|
||||||
import { apps } from "./webapp"
|
import { apps } from "./webapp/server"
|
||||||
import { expectDir } from "./utils"
|
import { expectDir } from "./utils"
|
||||||
import { NOSE_DIR } from "./config"
|
import { NOSE_DIR } from "./config"
|
||||||
import { expectShellCmd } from "./utils"
|
import { expectShellCmd } from "./utils"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
// import { css } from "@nose"
|
// import { css } from "@nose"
|
||||||
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { type Handler, toResponse } from "./webapp"
|
import { type Handler, toResponse } from "./webapp/server"
|
||||||
|
|
||||||
//
|
//
|
||||||
// command helpers
|
// command helpers
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import color from "kleur"
|
||||||
|
|
||||||
import type { Message } from "./shared/types"
|
import type { Message } from "./shared/types"
|
||||||
import { rewriteJsImports } from "./build"
|
import { rewriteJsImports } from "./build"
|
||||||
import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN, DEFAULT_PROJECT } from "./config"
|
import { NOSE_ICON, NOSE_BIN, NOSE_DATA, NOSE_DIR, NOSE_ROOT_BIN, BUN_BIN, DEFAULT_PROJECT } from "./config"
|
||||||
import { transpile, isFile, tilde, isDir } from "./utils"
|
import { transpile, isFile, tilde, isDir } from "./utils"
|
||||||
import { serveApp, apps, initWebapps } from "./webapp"
|
import { serveApp, apps, initWebapps } from "./webapp/server"
|
||||||
import { commands, commandPath, loadCommandModule } from "./commands"
|
import { commands, commandPath, loadCommandModule } from "./commands"
|
||||||
import { runCommandFn } from "./shell"
|
import { runCommandFn } from "./shell"
|
||||||
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
|
import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket"
|
||||||
|
|
@ -241,6 +241,7 @@ initWebapps()
|
||||||
initSneakers()
|
initSneakers()
|
||||||
|
|
||||||
console.log(color.cyan(NOSE_ICON))
|
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_BIN:"), color.yellow(tilde(NOSE_BIN)))
|
||||||
console.log(color.blue(" NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
|
console.log(color.blue(" NOSE_DATA:"), color.yellow(tilde(NOSE_DATA)))
|
||||||
console.log(color.blue(" NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
|
console.log(color.blue(" NOSE_DIR:"), color.yellow(tilde(NOSE_DIR)))
|
||||||
|
|
|
||||||
|
|
@ -5,36 +5,34 @@ import type { Child } from "hono/jsx"
|
||||||
import { type Context, Hono } from "hono"
|
import { type Context, Hono } from "hono"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { readdirSync, watch } from "fs"
|
import { readdirSync, watch } from "fs"
|
||||||
import { sendAll } from "./websocket"
|
import { sendAll } from "../websocket"
|
||||||
import { expectDir } from "./utils"
|
import { expectDir } from "../utils"
|
||||||
import { NOSE_DIR } from "./config"
|
import { NOSE_DIR, BUN_BIN } from "../config"
|
||||||
import { isFile, isDir, mtimeEpoch } from "./utils"
|
import { isFile, isDir } from "../utils"
|
||||||
import { importUrl } from "./shared/utils"
|
|
||||||
|
|
||||||
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
|
export type Handler = (r: Context) => string | Child | Response | Promise<Response>
|
||||||
export type App = Hono | Handler
|
export type App = Hono | Handler
|
||||||
|
|
||||||
|
const processes = new Map<string, { port: string, proc: ReturnType<typeof Bun.spawn> }>()
|
||||||
|
|
||||||
export function initWebapps() {
|
export function initWebapps() {
|
||||||
startWatcher()
|
startWatcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serveApp(c: Context, subdomain: string): Promise<Response> {
|
export async function serveApp(c: Context, subdomain: string): Promise<Response> {
|
||||||
const app = await findApp(subdomain)
|
const port = await startApp(subdomain)
|
||||||
const path = appDir(subdomain)
|
if (!port) return c.text(`App not found: ${subdomain}`, 404)
|
||||||
|
|
||||||
if (!path) 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)
|
||||||
|
|
||||||
const staticPath = join(path, "pub", c.req.path === "/" ? "/index.html" : c.req.path)
|
const res = await fetch(`http://localhost:${port}${c.req.path}`, {
|
||||||
|
method: c.req.method,
|
||||||
|
headers: c.req.raw.headers,
|
||||||
|
body: c.req.raw.body,
|
||||||
|
})
|
||||||
|
|
||||||
if (isFile(staticPath))
|
return new Response(res.body, { status: res.status, headers: res.headers })
|
||||||
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[] {
|
export function apps(): string[] {
|
||||||
|
|
@ -58,24 +56,23 @@ export function appDir(name: string): string | undefined {
|
||||||
return join(NOSE_DIR, name)
|
return join(NOSE_DIR, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findApp(name: string): Promise<App | undefined> {
|
async function startApp(name: string): Promise<string | undefined> {
|
||||||
const paths = [
|
const existing = processes.get(name)
|
||||||
join(name, "index.ts"),
|
if (existing) return existing.port
|
||||||
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> {
|
const port = String(4000 + processes.size)
|
||||||
if (!await Bun.file(path).exists()) return
|
const proc = Bun.spawn({
|
||||||
|
cmd: [BUN_BIN, "run", "src/webapp/worker.ts", name],
|
||||||
|
env: { PORT: port },
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
const mod = await importUrl(path, await mtimeEpoch(path))
|
processes.set(name, { port, proc })
|
||||||
if (mod?.default)
|
proc.exited.then(() => processes.delete(name))
|
||||||
return mod.default as App
|
|
||||||
|
return port
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toResponse(source: string | Child | Response): Promise<Response> {
|
export async function toResponse(source: string | Child | Response): Promise<Response> {
|
||||||
|
|
@ -100,6 +97,14 @@ function serveStatic(path: string): Response {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
for (const [name, { port, proc }] of processes) {
|
||||||
|
console.log(`Shutting down ${name}`)
|
||||||
|
try { proc.kill() } catch { }
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
let wwwWatcher
|
let wwwWatcher
|
||||||
function startWatcher() {
|
function startWatcher() {
|
||||||
if (!expectDir(NOSE_DIR)) return
|
if (!expectDir(NOSE_DIR)) return
|
||||||
|
|
@ -110,4 +115,7 @@ function startWatcher() {
|
||||||
if (/^.+\/index\.tsx?$/.test(filename))
|
if (/^.+\/index\.tsx?$/.test(filename))
|
||||||
sendAll({ type: "apps", data: apps() })
|
sendAll({ type: "apps", data: apps() })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown)
|
||||||
|
process.on("SIGTERM", shutdown)
|
||||||
9
src/webapp/utils.ts
Normal file
9
src/webapp/utils.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { join } from "path"
|
||||||
|
import { NOSE_DIR } from "../config"
|
||||||
|
|
||||||
|
export async function appPath(appName: string): Promise<string | undefined> {
|
||||||
|
const ts = join(NOSE_DIR, appName, "index.ts")
|
||||||
|
const tsx = join(NOSE_DIR, appName, "index.tsx")
|
||||||
|
|
||||||
|
return [ts, tsx].find(async file => await Bun.file(file).exists())
|
||||||
|
}
|
||||||
26
src/webapp/worker.ts
Normal file
26
src/webapp/worker.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
////
|
||||||
|
// This is the child process that runs a single webapp.
|
||||||
|
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { appPath } from "./utils"
|
||||||
|
|
||||||
|
const appName = Bun.argv[2]
|
||||||
|
if (!appName) {
|
||||||
|
console.log("usage: bun run webapp-worker <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("*", handler)
|
||||||
|
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