Compare commits
4 Commits
36c7913b6c
...
520606ccb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 520606ccb9 | |||
| 26409010a8 | |||
| 236e8ff38e | |||
| 4aebd6a087 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
44
src/shared/urls.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user