diff --git a/src/server/apps.ts b/src/server/apps.ts index b19fee1..d388574 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -2,7 +2,7 @@ import type { App as SharedApp, AppState } from '@types' import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events' import type { Subprocess } from 'bun' 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 { hostname } from 'os' import { join, resolve } from 'path' @@ -60,6 +60,9 @@ export const allApps = (): App[] => export const getApp = (dir: string): App | undefined => _apps.get(dir) +export const getAppBySubdomain = (subdomain: string): App | undefined => + _apps.get(subdomain) ?? allApps().find(a => toSubdomain(a.name) === subdomain) + export const runApps = () => allAppDirs().filter(isApp).forEach(startApp) diff --git a/src/server/mdns.ts b/src/server/mdns.ts index ebdb186..e8a74a4 100644 --- a/src/server/mdns.ts +++ b/src/server/mdns.ts @@ -1,4 +1,5 @@ import type { Subprocess } from 'bun' +import { toSubdomain } from '@urls' import { networkInterfaces } from 'os' import { hostLog } from './tui' @@ -40,7 +41,7 @@ export function publishApp(name: string) { return } - const hostname = `${name}.toes.local` + const hostname = `${toSubdomain(name)}.toes.local` try { const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], { @@ -67,7 +68,7 @@ export function unpublishApp(name: string) { proc.kill() _publishers.delete(name) - hostLog(`mDNS: unpublished ${name}.toes.local`) + hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`) } export function unpublishAll() { @@ -75,7 +76,7 @@ export function unpublishAll() { for (const [name, proc] of _publishers) { proc.kill() - hostLog(`mDNS: unpublished ${name}.toes.local`) + hostLog(`mDNS: unpublished ${toSubdomain(name)}.toes.local`) } _publishers.clear() } diff --git a/src/server/proxy.ts b/src/server/proxy.ts index fbe522b..881ac47 100644 --- a/src/server/proxy.ts +++ b/src/server/proxy.ts @@ -1,5 +1,5 @@ import type { Server, ServerWebSocket } from 'bun' -import { getApp } from '$apps' +import { getAppBySubdomain } from '$apps' export type { WsData } @@ -34,7 +34,7 @@ export function extractSubdomain(host: string): string | null { } export async function proxySubdomain(subdomain: string, req: Request): Promise { - const app = getApp(subdomain) + const app = getAppBySubdomain(subdomain) if (!app || app.state !== 'running' || !app.port) { 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): Response | undefined { - const app = getApp(subdomain) + const app = getAppBySubdomain(subdomain) if (!app || app.state !== 'running' || !app.port) { return new Response(`App "${subdomain}" not found or not running`, { status: 502 }) diff --git a/src/shared/urls.test.ts b/src/shared/urls.test.ts new file mode 100644 index 0000000..3b85702 --- /dev/null +++ b/src/shared/urls.test.ts @@ -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') + }) +}) diff --git a/src/shared/urls.ts b/src/shared/urls.ts index 11de449..fdc20ce 100644 --- a/src/shared/urls.ts +++ b/src/shared/urls.ts @@ -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 { const url = new URL(baseUrl) - url.hostname = `${appName}.${url.hostname}` + url.hostname = `${toSubdomain(appName)}.${url.hostname}` return url.origin }