Compare commits

..

4 Commits

6 changed files with 65 additions and 9 deletions

View File

@ -57,6 +57,8 @@ toes open
Your app is now running at `http://my-app.toes.local`. Your app is now running at `http://my-app.toes.local`.
> **Tip:** Add `.toes` to your `.gitignore`. This file tracks local sync state and shouldn't be committed.
--- ---
## Creating an App ## Creating an App

View File

@ -2,7 +2,7 @@ import type { App as SharedApp, AppState } from '@types'
import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events' import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events'
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { DEFAULT_EMOJI } from '@types' import { DEFAULT_EMOJI } from '@types'
import { buildAppUrl } from '@urls' import { buildAppUrl, toSubdomain } from '@urls'
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs' import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
import { hostname } from 'os' import { hostname } from 'os'
import { join, resolve } from 'path' import { join, resolve } from 'path'
@ -60,6 +60,9 @@ export const allApps = (): App[] =>
export const getApp = (dir: string): App | undefined => export const getApp = (dir: string): App | undefined =>
_apps.get(dir) _apps.get(dir)
export const getAppBySubdomain = (subdomain: string): App | undefined =>
_apps.get(subdomain) ?? allApps().find(a => toSubdomain(a.name) === subdomain)
export const runApps = () => export const runApps = () =>
allAppDirs().filter(isApp).forEach(startApp) allAppDirs().filter(isApp).forEach(startApp)
@ -644,9 +647,12 @@ async function runApp(dir: string, port: number) {
// Load env vars from TOES_DIR/env/ // Load env vars from TOES_DIR/env/
const appEnv = loadAppEnv(dir, TOES_DIR) const appEnv = loadAppEnv(dir, TOES_DIR)
const dataDir = join(process.env.DATA_DIR ?? '.', 'toes', dir)
mkdirSync(dataDir, { recursive: true })
const proc = Bun.spawn(['bun', 'run', 'toes'], { const proc = Bun.spawn(['bun', 'run', 'toes'], {
cwd, cwd,
env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', APP_URL: buildAppUrl(dir, TOES_URL), APPS_DIR, DATA_DIR: join(process.env.DATA_DIR ?? '.', 'toes', dir), TOES_DIR, TOES_URL }, env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', APP_URL: buildAppUrl(dir, TOES_URL), APPS_DIR, DATA_DIR: dataDir, TOES_DIR, TOES_URL },
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
}) })

View File

@ -1,4 +1,5 @@
import type { Subprocess } from 'bun' import type { Subprocess } from 'bun'
import { toSubdomain } from '@urls'
import { networkInterfaces } from 'os' import { networkInterfaces } from 'os'
import { hostLog } from './tui' import { hostLog } from './tui'
@ -40,7 +41,7 @@ export function publishApp(name: string) {
return return
} }
const hostname = `${name}.toes.local` const hostname = `${toSubdomain(name)}.toes.local`
try { try {
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], { const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
@ -67,7 +68,7 @@ export function unpublishApp(name: string) {
proc.kill() proc.kill()
_publishers.delete(name) _publishers.delete(name)
hostLog(`mDNS: unpublished ${name}.toes.local`) hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
} }
export function unpublishAll() { export function unpublishAll() {
@ -75,7 +76,7 @@ export function unpublishAll() {
for (const [name, proc] of _publishers) { for (const [name, proc] of _publishers) {
proc.kill() proc.kill()
hostLog(`mDNS: unpublished ${name}.toes.local`) hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`)
} }
_publishers.clear() _publishers.clear()
} }

View File

@ -1,5 +1,5 @@
import type { Server, ServerWebSocket } from 'bun' import type { Server, ServerWebSocket } from 'bun'
import { getApp } from '$apps' import { getAppBySubdomain } from '$apps'
export type { WsData } export type { WsData }
@ -34,7 +34,7 @@ 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 = getApp(subdomain) const app = getAppBySubdomain(subdomain)
if (!app || app.state !== 'running' || !app.port) { if (!app || app.state !== 'running' || !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 })
@ -66,7 +66,7 @@ export async function proxySubdomain(subdomain: string, req: Request): Promise<R
} }
export function proxyWebSocket(subdomain: string, req: Request, server: Server<WsData>): Response | undefined { export function proxyWebSocket(subdomain: string, req: Request, server: Server<WsData>): Response | undefined {
const app = getApp(subdomain) const app = getAppBySubdomain(subdomain)
if (!app || app.state !== 'running' || !app.port) { if (!app || app.state !== 'running' || !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 })

44
src/shared/urls.test.ts Normal file
View File

@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test'
import { buildAppUrl, toSubdomain } from './urls'
describe('toSubdomain', () => {
test('passes through clean names', () => {
expect(toSubdomain('clock')).toBe('clock')
expect(toSubdomain('my-app')).toBe('my-app')
})
test('replaces dots with hyphens', () => {
expect(toSubdomain('mcp.txt')).toBe('mcp-txt')
expect(toSubdomain('my.cool.app')).toBe('my-cool-app')
})
test('replaces underscores with hyphens', () => {
expect(toSubdomain('my_app')).toBe('my-app')
})
test('collapses multiple hyphens', () => {
expect(toSubdomain('my--app')).toBe('my-app')
expect(toSubdomain('a..b')).toBe('a-b')
})
test('strips leading and trailing hyphens', () => {
expect(toSubdomain('.hidden')).toBe('hidden')
expect(toSubdomain('trailing.')).toBe('trailing')
expect(toSubdomain('-dashed-')).toBe('dashed')
})
test('lowercases', () => {
expect(toSubdomain('MyApp')).toBe('myapp')
})
})
describe('buildAppUrl', () => {
test('prepends sanitized name as subdomain', () => {
expect(buildAppUrl('clock', 'http://toes.local')).toBe('http://clock.toes.local')
expect(buildAppUrl('mcp.txt', 'http://toes.local')).toBe('http://mcp-txt.toes.local')
})
test('works with localhost and port', () => {
expect(buildAppUrl('my-app', 'http://localhost:3000')).toBe('http://my-app.localhost:3000')
})
})

View File

@ -1,5 +1,8 @@
export const toSubdomain = (name: string): string =>
name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
export function buildAppUrl(appName: string, baseUrl: string): string { export function buildAppUrl(appName: string, baseUrl: string): string {
const url = new URL(baseUrl) const url = new URL(baseUrl)
url.hostname = `${appName}.${url.hostname}` url.hostname = `${toSubdomain(appName)}.${url.hostname}`
return url.origin return url.origin
} }