Add static site support to apps
This commit is contained in:
parent
44dc7527fe
commit
c0276389eb
61
CLAUDE.md
61
CLAUDE.md
|
|
@ -48,10 +48,16 @@ Path aliases: `$` = server, `@` = shared, `%` = lib (defined in tsconfig.json).
|
||||||
|
|
||||||
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
|
- `apps.ts` -- **The heart**: app discovery, process spawning, health checks, auto-restart, port allocation, log management, graceful shutdown. Exports `APPS_DIR`, `TOES_DIR`, `TOES_URL`, and the `App` type (extends shared `App` with process/timer fields).
|
||||||
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
|
- `api/apps.ts` -- REST API + SSE stream. Routes: `GET /` (list), `GET /stream` (SSE), `POST /:name/start|stop|restart`, `GET /:name/logs`, `POST /` (create via git), `POST /:name/rename`, `POST /:name/icon`, env var CRUD, tunnel management.
|
||||||
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app reload (triggered by git tool after deploy), file watch SSE.
|
- `api/events.ts` -- SSE stream for discrete lifecycle events (`/stream`). Used by app processes (not the dashboard). Includes a 60s heartbeat ping.
|
||||||
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), initializes apps.
|
- `api/sync.ts` -- File sync API: manifest endpoint, file read/write, app delete, app reload (triggered by git tool after deploy), file watch SSE.
|
||||||
|
- `api/system.ts` -- System info, metrics (CPU/RAM/disk per-app via `ps`/`du`), unified log aggregation, perf timing toggle, update check/apply, server restart.
|
||||||
|
- `index.tsx` -- Entry point. Mounts API routers, tool URL redirects (`/tool/:tool`), tool API proxy (`/api/tools/:tool/*`), on-demand CLI binary builds (`/dist/:file`), install script endpoint (`/install`), SPA catch-all routes, subdomain proxy (including WebSocket). Initializes apps.
|
||||||
|
- `mdns.ts` -- mDNS publishing via `avahi-publish` on Linux production. Publishes `<app>.hostname.local` A records pointing to local IP. Auto-republishes on unexpected exit with exponential backoff.
|
||||||
|
- `proxy.ts` -- Subdomain routing: extracts subdomain from `*.localhost` or `*.X.local`, proxies HTTP requests and WebSocket connections to the app's port. Sets `x-app-url` header. Optional perf timing.
|
||||||
- `shell.tsx` -- Minimal HTML shell for the SPA.
|
- `shell.tsx` -- Minimal HTML shell for the SPA.
|
||||||
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY).
|
- `sync.ts` -- Re-exports `computeHash` and `generateManifest` from `%sync` (lib).
|
||||||
|
- `tui.ts` -- Terminal UI for the server process (renders app status table when TTY, plain logs otherwise). Debounced rendering at 50ms.
|
||||||
|
- `tunnels.ts` -- Public tunnel management via `@because/sneaker`. Persists tunnel config in `TOES_DIR/tunnels.json`. Auto-reconnects on drop with exponential backoff (max 10 attempts). Manages share/unshare lifecycle.
|
||||||
|
|
||||||
### Client (`src/client/`)
|
### Client (`src/client/`)
|
||||||
|
|
||||||
|
|
@ -62,9 +68,11 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
|
||||||
- `api.ts` -- Fetch wrappers for server API calls.
|
- `api.ts` -- Fetch wrappers for server API calls.
|
||||||
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
|
- `tool-iframes.ts` -- Manages tool iframe lifecycle (caching, visibility, height communication).
|
||||||
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates.
|
- `update.tsx` -- SSE connection to `/api/apps/stream` for real-time state updates.
|
||||||
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection.
|
- `router.ts` -- Client-side router. Intercepts link clicks, handles popstate, maps URL paths to state (`/app/:name/:tab`, `/settings`, dashboard tabs).
|
||||||
|
- `ansi.ts` -- ANSI escape code handling for log rendering.
|
||||||
|
- `components/` -- Dashboard, Sidebar, AppDetail, Nav, AppSelector, LogsSection, DashboardLanding, SettingsPage, Vitals, UnifiedLogs, Urls, emoji-picker, modal.
|
||||||
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
|
- `modals/` -- NewApp, RenameApp, DeleteApp dialogs.
|
||||||
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout).
|
- `styles/` -- Forge CSS-in-JS (themes, buttons, forms, layout, logs, misc).
|
||||||
- `themes/` -- Light/dark theme token definitions.
|
- `themes/` -- Light/dark theme token definitions.
|
||||||
|
|
||||||
### CLI (`src/cli/`)
|
### CLI (`src/cli/`)
|
||||||
|
|
@ -78,30 +86,37 @@ Client-side SPA rendered with `hono/jsx/dom`. No build step -- Bun serves `.tsx`
|
||||||
- `pager.ts` -- Pipe output through system pager.
|
- `pager.ts` -- Pipe output through system pager.
|
||||||
|
|
||||||
CLI commands:
|
CLI commands:
|
||||||
- **Apps**: `list`, `info`, `new`, `get`, `open`, `rename`, `rm`
|
- **Apps**: `status` (list or info), `new`, `get`, `open`, `rename`, `rm`
|
||||||
- **Lifecycle**: `start`, `stop`, `restart`, `logs`, `metrics`, `cron`, `share`, `unshare`
|
- **Lifecycle**: `start`, `stop`, `restart`, `share`, `unshare`, `logs`, `metrics`, `cron` (list/log/status/run)
|
||||||
- **Config**: `env`
|
- **Config**: `env` (list/set/rm, per-app or `--global`), `perf` (toggle request timing)
|
||||||
|
- **Hidden**: `list`, `info`, `log`, `version`, `shell`
|
||||||
|
|
||||||
|
Some commands (`shell`, `get`, `open`) are disabled when running over SSH (`USER=cli`).
|
||||||
|
|
||||||
### Shared (`src/shared/`)
|
### Shared (`src/shared/`)
|
||||||
|
|
||||||
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
|
Types shared between browser and server. **Cannot use Node/filesystem APIs** (runs in browser).
|
||||||
|
|
||||||
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`
|
- `types.ts` -- `App`, `AppState`, `LogLine`, `Manifest`, `FileInfo`, `DEFAULT_EMOJI`, `VALID_NAME`
|
||||||
|
- `events.ts` -- `ToesEvent`, `ToesEventType`, `ToesEventInput` type definitions
|
||||||
|
- `urls.ts` -- `toSubdomain()` and `buildAppUrl()` for subdomain URL construction
|
||||||
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
|
- `gitignore.ts` -- `.gitignore` pattern matching (used by sync API and file watchers)
|
||||||
|
|
||||||
### Lib (`src/lib/`)
|
### Lib (`src/lib/`)
|
||||||
|
|
||||||
Server-side code shared between CLI and server. Can use Node APIs.
|
Server-side code shared between CLI and server. Can use Node APIs.
|
||||||
|
|
||||||
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa)
|
- `config.ts` -- `HOSTNAME` and `LOCAL_HOST` (`hostname.local`) constants
|
||||||
|
- `templates.ts` -- Template generation for `toes new` (bare, ssr, spa), reads embedded templates from `templates.data.ts`
|
||||||
|
- `templates.data.ts` -- Generated file containing embedded template contents (built by `scripts/embed-templates.ts`)
|
||||||
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
|
- `sync.ts` -- Manifest generation, hash computation (used by sync API for file diffing in tools)
|
||||||
|
|
||||||
### Tools Package (`src/tools/`)
|
### Tools Package (`src/tools/`)
|
||||||
|
|
||||||
The `@because/toes` package that apps/tools import. Published exports:
|
The `@because/toes` package that apps/tools import. Published exports:
|
||||||
|
|
||||||
- `@because/toes` -- re-exports from server (`src/index.ts` -> `src/server/sync.ts`)
|
- `@because/toes` -- re-exports `computeHash`, `generateManifest`, `FileInfo`, `Manifest`, `VALID_NAME` (`src/index.ts` -> `src/tools/index.ts`)
|
||||||
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`
|
- `@because/toes/tools` -- `baseStyles`, `ToolScript`, `theme`, `loadAppEnv`, `on` (event subscription), `appUrl`, `VALID_NAME`
|
||||||
|
|
||||||
### Pages (`src/pages/`)
|
### Pages (`src/pages/`)
|
||||||
|
|
||||||
|
|
@ -111,17 +126,25 @@ Hype page routes. `index.tsx` renders the Shell.
|
||||||
|
|
||||||
### App Lifecycle
|
### App Lifecycle
|
||||||
|
|
||||||
States: `invalid` -> `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
|
States: `invalid` | `error` | `stopped` <-> `starting` -> `running` -> `stopping` -> `stopped`
|
||||||
|
|
||||||
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
|
- Discovery: scan `APPS_DIR`, read each `package.json` for `scripts.toes`
|
||||||
- Spawn: `Bun.spawn()` with `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, plus per-app env vars
|
- Spawn: `Bun.spawn(['bun', 'run', 'toes'])` with `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR`, `DATA_ROOT`, `TOES_URL`, `TOES_DIR`, `NO_AUTOPORT`, plus per-app env vars from `loadAppEnv()`
|
||||||
- Health checks: every 30s to `/ok`, 3 consecutive failures trigger restart
|
- Startup: runs `bun install` first if `node_modules/` missing, then polls `/ok` every 500ms (30s timeout)
|
||||||
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), resets after 60s stable run
|
- Health checks: every 30s to `/ok` (5s timeout), 3 consecutive failures trigger restart
|
||||||
|
- Auto-restart: exponential backoff (1s, 2s, 4s, 8s, 16s, 32s), max 5 attempts, resets after 60s stable run. State becomes `error` after max attempts.
|
||||||
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
|
- Graceful shutdown: SIGTERM with 10s timeout before SIGKILL
|
||||||
|
- On startup, kills stale processes on ports 3001-3100 and orphaned `bun run toes` processes
|
||||||
|
|
||||||
|
### Subdomain Proxy
|
||||||
|
|
||||||
|
Every app gets a subdomain: `<app>.localhost` (dev) or `<app>.hostname.local` (prod). The server's fetch handler (`index.tsx`) checks for subdomains first and proxies to the app's port. WebSocket connections are also proxied via Bun's `server.upgrade()` with upstream bridging. The `x-app-url` header is set so apps know their public URL.
|
||||||
|
|
||||||
### Tools vs Apps
|
### Tools vs Apps
|
||||||
|
|
||||||
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server: `/tool/:tool?app=foo` -> `http://host:toolPort/?app=foo`.
|
Tools are apps with `"toes": { "tool": true }` in package.json. From the server's perspective they're identical processes. The dashboard renders tools as iframe tabs instead of sidebar entries. Tool URLs redirect through the server via subdomain: `/tool/:tool?app=foo` -> `http://<tool>.host/?app=foo`. Tool API calls can also be proxied: `/api/tools/:tool/*` -> `http://localhost:<toolPort>/*`.
|
||||||
|
|
||||||
|
The `apps` field in package.json controls whether a tool shows on app detail pages (`false` to hide). The `dashboard` field controls whether a tool shows on the dashboard landing page.
|
||||||
|
|
||||||
### Versioning
|
### Versioning
|
||||||
|
|
||||||
|
|
@ -133,14 +156,14 @@ Per-app env files in `TOES_DIR/env/`:
|
||||||
- `_global.env` -- shared by all apps
|
- `_global.env` -- shared by all apps
|
||||||
- `<appname>.env` -- per-app overrides
|
- `<appname>.env` -- per-app overrides
|
||||||
|
|
||||||
The server sets these on each app process: `PORT`, `APPS_DIR`, `TOES_URL`, `TOES_DIR`, `DATA_DIR`.
|
The server sets these on each app process: `PORT`, `APP_URL`, `APPS_DIR`, `DATA_DIR` (per-app at `TOES_DIR/<name>`), `DATA_ROOT`, `TOES_DIR`, `TOES_URL`, `NO_AUTOPORT`.
|
||||||
|
|
||||||
### SSE Streaming
|
### SSE Streaming
|
||||||
|
|
||||||
Two SSE endpoints serve different consumers:
|
Two SSE endpoints serve different consumers:
|
||||||
|
|
||||||
- `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`.
|
- `/api/apps/stream` -- Full app state snapshots on every change. Used by the dashboard UI. Driven by `onChange()` in `apps.ts`.
|
||||||
- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:activate`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
|
- `/api/events/stream` -- Discrete lifecycle events (`app:start`, `app:stop`, `app:reload`, `app:create`, `app:delete`). Used by app processes to react to other apps' lifecycle changes. Driven by `emit()`/`onEvent()` in `apps.ts`. Apps subscribe via `on()` from `@because/toes/tools`.
|
||||||
|
|
||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,14 @@ export function registerApp(dir: string) {
|
||||||
if (_apps.has(dir)) return // Already registered
|
if (_apps.has(dir)) return // Already registered
|
||||||
|
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
const isStatic = !!pkg.toes?.static
|
||||||
_apps.set(dir, buildApp(dir, pkg, state, error))
|
const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped')
|
||||||
|
const app = buildApp(dir, pkg, state, error)
|
||||||
|
if (isStatic) app.started = Date.now()
|
||||||
|
_apps.set(dir, app)
|
||||||
update()
|
update()
|
||||||
emit({ type: 'app:create', app: dir })
|
emit({ type: 'app:create', app: dir })
|
||||||
if (!error) {
|
if (!error && !isStatic) {
|
||||||
runApp(dir, getPort(dir))
|
runApp(dir, getPort(dir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +230,17 @@ export async function renameApp(oldName: string, newName: string): Promise<{ ok:
|
||||||
export function startApp(dir: string) {
|
export function startApp(dir: string) {
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app || (app.state !== 'stopped' && app.state !== 'invalid' && app.state !== 'error')) return
|
if (!app || (app.state !== 'stopped' && app.state !== 'invalid' && app.state !== 'error')) return
|
||||||
|
|
||||||
|
// Static apps don't need a process
|
||||||
|
if (app.static) {
|
||||||
|
app.state = 'running'
|
||||||
|
app.started = Date.now()
|
||||||
|
app.manuallyStopped = false
|
||||||
|
update()
|
||||||
|
emit({ type: 'app:start', app: dir })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!isApp(dir)) return
|
if (!isApp(dir)) return
|
||||||
|
|
||||||
// Clear flags when explicitly starting
|
// Clear flags when explicitly starting
|
||||||
|
|
@ -240,6 +254,14 @@ export async function restartApp(dir: string): Promise<void> {
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app) return
|
if (!app) return
|
||||||
|
|
||||||
|
// Static apps just ensure running state
|
||||||
|
if (app.static) {
|
||||||
|
app.state = 'running'
|
||||||
|
app.started = Date.now()
|
||||||
|
update()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Stop if running
|
// Stop if running
|
||||||
if (app.state === 'running' || app.state === 'starting') {
|
if (app.state === 'running' || app.state === 'starting') {
|
||||||
stopApp(dir)
|
stopApp(dir)
|
||||||
|
|
@ -267,6 +289,16 @@ export function stopApp(dir: string) {
|
||||||
const app = _apps.get(dir)
|
const app = _apps.get(dir)
|
||||||
if (!app || app.state !== 'running') return
|
if (!app || app.state !== 'running') return
|
||||||
|
|
||||||
|
// Static apps just toggle state
|
||||||
|
if (app.static) {
|
||||||
|
app.state = 'stopped'
|
||||||
|
app.started = undefined
|
||||||
|
app.manuallyStopped = true
|
||||||
|
update()
|
||||||
|
emit({ type: 'app:stop', app: dir })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info(app, 'Stopping...')
|
info(app, 'Stopping...')
|
||||||
app.state = 'stopping'
|
app.state = 'stopping'
|
||||||
app.manuallyStopped = true
|
app.manuallyStopped = true
|
||||||
|
|
@ -306,6 +338,7 @@ const buildApp = (dir: string, pkg: any, state: AppState, error?: string): App =
|
||||||
apps: pkg.toes?.apps,
|
apps: pkg.toes?.apps,
|
||||||
dashboard: pkg.toes?.dashboard,
|
dashboard: pkg.toes?.dashboard,
|
||||||
share: pkg.toes?.share,
|
share: pkg.toes?.share,
|
||||||
|
static: pkg.toes?.static,
|
||||||
})
|
})
|
||||||
|
|
||||||
const clearTimers = (app: App) => {
|
const clearTimers = (app: App) => {
|
||||||
|
|
@ -355,8 +388,11 @@ function allAppDirs() {
|
||||||
function discoverApps() {
|
function discoverApps() {
|
||||||
for (const dir of allAppDirs()) {
|
for (const dir of allAppDirs()) {
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
const isStatic = !!pkg.toes?.static
|
||||||
_apps.set(dir, buildApp(dir, pkg, state, error))
|
const state: AppState = error ? 'invalid' : (isStatic ? 'running' : 'stopped')
|
||||||
|
const app = buildApp(dir, pkg, state, error)
|
||||||
|
if (isStatic) app.started = Date.now()
|
||||||
|
_apps.set(dir, app)
|
||||||
}
|
}
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
@ -542,6 +578,8 @@ function loadApp(dir: string): LoadResult {
|
||||||
|
|
||||||
if (json.scripts?.toes) {
|
if (json.scripts?.toes) {
|
||||||
return { pkg: json }
|
return { pkg: json }
|
||||||
|
} else if (hasPublicDir(dir)) {
|
||||||
|
return { pkg: { ...json, toes: { ...json.toes, static: true } } }
|
||||||
} else {
|
} else {
|
||||||
return { pkg: json, error: 'Missing scripts.toes in package.json' }
|
return { pkg: json, error: 'Missing scripts.toes in package.json' }
|
||||||
}
|
}
|
||||||
|
|
@ -550,10 +588,17 @@ function loadApp(dir: string): LoadResult {
|
||||||
return { pkg: {}, error }
|
return { pkg: {}, error }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// No package.json — check for pub/ directory (static site)
|
||||||
|
if (hasPublicDir(dir)) {
|
||||||
|
return { pkg: { toes: { static: true, icon: '📄' } } }
|
||||||
|
}
|
||||||
return { pkg: {}, error: 'Missing package.json' }
|
return { pkg: {}, error: 'Missing package.json' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPublicDir = (dir: string): boolean =>
|
||||||
|
existsSync(join(APPS_DIR, dir, 'pub'))
|
||||||
|
|
||||||
|
|
||||||
function maybeResetBackoff(app: App) {
|
function maybeResetBackoff(app: App) {
|
||||||
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {
|
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Server, ServerWebSocket } from 'bun'
|
import type { Server, ServerWebSocket } from 'bun'
|
||||||
import { getAppBySubdomain } from '$apps'
|
import { getAppBySubdomain } from '$apps'
|
||||||
|
import { serveStatic } from '$static'
|
||||||
|
|
||||||
export const perf = { timing: false }
|
export const perf = { timing: false }
|
||||||
|
|
||||||
|
|
@ -38,7 +39,16 @@ export function extractSubdomain(host: string): string | null {
|
||||||
export async function proxySubdomain(subdomain: string, req: Request): Promise<Response> {
|
export async function proxySubdomain(subdomain: string, req: Request): Promise<Response> {
|
||||||
const app = getAppBySubdomain(subdomain)
|
const app = getAppBySubdomain(subdomain)
|
||||||
|
|
||||||
if (!app || app.state !== 'running' || !app.port) {
|
if (!app || app.state !== 'running') {
|
||||||
|
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static apps: serve from pub/ directory
|
||||||
|
if (app.static) {
|
||||||
|
return serveStatic(app.name, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.port) {
|
||||||
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
|
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
92
src/server/static.ts
Normal file
92
src/server/static.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { APPS_DIR } from '$apps'
|
||||||
|
import { existsSync, readdirSync, statSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export async function serveStatic(appName: string, req: Request): Promise<Response> {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const pathname = decodeURIComponent(url.pathname)
|
||||||
|
const pubDir = join(APPS_DIR, appName, 'pub')
|
||||||
|
|
||||||
|
// Resolve the file path, preventing directory traversal
|
||||||
|
const filePath = join(pubDir, pathname)
|
||||||
|
if (!filePath.startsWith(pubDir)) {
|
||||||
|
return new Response('Forbidden', { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory: try index.html, then file listing
|
||||||
|
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
|
||||||
|
const indexPath = join(filePath, 'index.html')
|
||||||
|
if (existsSync(indexPath)) {
|
||||||
|
return new Response(Bun.file(indexPath))
|
||||||
|
}
|
||||||
|
return fileListing(appName, pathname, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact file match
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
return new Response(Bun.file(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean URLs: try .html extension
|
||||||
|
const htmlPath = filePath + '.html'
|
||||||
|
if (existsSync(htmlPath)) {
|
||||||
|
return new Response(Bun.file(htmlPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileListing(appName: string, pathname: string, dirPath: string): Response {
|
||||||
|
const trail = pathname.endsWith('/') ? pathname : pathname + '/'
|
||||||
|
|
||||||
|
const entries = readdirSync(dirPath, { withFileTypes: true })
|
||||||
|
.filter(e => !e.name.startsWith('.'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isDirectory() && !b.isDirectory()) return -1
|
||||||
|
if (!a.isDirectory() && b.isDirectory()) return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = entries.map(e => {
|
||||||
|
const display = e.isDirectory() ? `${e.name}/` : e.name
|
||||||
|
const href = `${trail}${e.name}`
|
||||||
|
const stat = statSync(join(dirPath, e.name))
|
||||||
|
const size = e.isDirectory() ? '—' : formatSize(stat.size)
|
||||||
|
return ` <tr><td><a href="${href}">${display}</a></td><td>${size}</td></tr>`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
const parent = pathname !== '/'
|
||||||
|
? ` <tr><td><a href="${trail.replace(/[^/]+\/$/, '')}">..</a></td><td></td></tr>\n`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${appName} — ${pathname}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
td { padding: 0.4rem 0.8rem; border-bottom: 1px solid #eee; }
|
||||||
|
td:last-child { text-align: right; color: #666; }
|
||||||
|
a { color: #0366d6; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${appName}${pathname}</h1>
|
||||||
|
<table>
|
||||||
|
${parent}${rows}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return new Response(html, { headers: { 'content-type': 'text/html' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string =>
|
||||||
|
bytes < 1024 ? `${bytes} B`
|
||||||
|
: bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
: `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
|
@ -32,6 +32,7 @@ export type App = {
|
||||||
apps?: boolean
|
apps?: boolean
|
||||||
dashboard?: boolean
|
dashboard?: boolean
|
||||||
share?: boolean
|
share?: boolean
|
||||||
|
static?: boolean
|
||||||
tunnelEnabled?: boolean
|
tunnelEnabled?: boolean
|
||||||
tunnelUrl?: string
|
tunnelUrl?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user