subdomains
This commit is contained in:
parent
9c0762c882
commit
86dacb0a74
|
|
@ -4,7 +4,8 @@ import { readSyncState } from '%sync'
|
|||
import color from 'kleur'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { basename, join } from 'path'
|
||||
import { del, get, getManifest, HOST, makeAppUrl, post } from '../http'
|
||||
import { buildAppUrl } from '@urls'
|
||||
import { del, get, getManifest, HOST, post } from '../http'
|
||||
import { confirm, prompt } from '../prompts'
|
||||
import { resolveAppName } from '../name'
|
||||
import { pushApp } from './sync'
|
||||
|
|
@ -57,9 +58,11 @@ export async function infoApp(arg?: string) {
|
|||
const icon = STATE_ICONS[app.state] ?? '◯'
|
||||
console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`)
|
||||
console.log(` State: ${app.state}`)
|
||||
if (app.state === 'running') {
|
||||
console.log(` URL: ${buildAppUrl(app.name, HOST)}`)
|
||||
}
|
||||
if (app.port) {
|
||||
console.log(` Port: ${app.port}`)
|
||||
console.log(` URL: ${makeAppUrl(app.port)}`)
|
||||
}
|
||||
if (app.tunnelUrl) {
|
||||
console.log(` Tunnel: ${app.tunnelUrl}`)
|
||||
|
|
@ -207,7 +210,7 @@ export async function openApp(arg?: string) {
|
|||
console.error(`App is not running: ${name}`)
|
||||
return
|
||||
}
|
||||
const url = makeAppUrl(app.port!)
|
||||
const url = buildAppUrl(app.name, HOST)
|
||||
console.log(`Opening ${url}`)
|
||||
Bun.spawn(['open', url])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,6 @@ export function makeUrl(path: string): string {
|
|||
return `${HOST}${path}`
|
||||
}
|
||||
|
||||
export function makeAppUrl(port: number): string {
|
||||
const url = new URL(HOST)
|
||||
url.port = String(port)
|
||||
return url.toString().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function handleError(error: unknown): void {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
|
||||
console.error(`🐾 Can't connect to toes server at ${HOST}`)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ export const getLogDates = (name: string): Promise<string[]> =>
|
|||
export const getLogsForDate = (name: string, date: string): Promise<string[]> =>
|
||||
fetch(`/api/apps/${name}/logs?date=${date}`).then(r => r.json())
|
||||
|
||||
export const disableTunnel = (name: string) =>
|
||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||
|
||||
export const enableTunnel = (name: string) =>
|
||||
export const shareApp = (name: string) =>
|
||||
fetch(`/api/apps/${name}/tunnel`, { method: 'POST' })
|
||||
|
||||
export const unshareApp = (name: string) =>
|
||||
fetch(`/api/apps/${name}/tunnel`, { method: 'DELETE' })
|
||||
|
||||
export const restartApp = (name: string) => fetch(`/api/apps/${name}/restart`, { method: 'POST' })
|
||||
|
||||
export const startApp = (name: string) => fetch(`/api/apps/${name}/start`, { method: 'POST' })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { define } from '@because/forge'
|
||||
import type { App } from '../../shared/types'
|
||||
import { disableTunnel, enableTunnel, restartApp, startApp, stopApp } from '../api'
|
||||
import { buildAppUrl } from '../../shared/urls'
|
||||
import { restartApp, shareApp, startApp, stopApp, unshareApp } from '../api'
|
||||
import { openAppSelectorModal, openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||
import { apps, getSelectedTab, isNarrow } from '../state'
|
||||
import {
|
||||
|
|
@ -63,10 +64,10 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
|||
<HeaderActions>
|
||||
{!app.tool && (
|
||||
app.tunnelUrl
|
||||
? <Button onClick={() => { disableTunnel(app.name) }}>Unshare</Button>
|
||||
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
|
||||
: app.tunnelEnabled
|
||||
? <Button disabled>Sharing...</Button>
|
||||
: <Button disabled={app.state !== 'running'} onClick={() => { enableTunnel(app.name) }}>Share</Button>
|
||||
: <Button disabled={app.state !== 'running'} onClick={() => { shareApp(app.name) }}>Share</Button>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => openDeleteAppModal(app)}>Delete</Button>
|
||||
</HeaderActions>
|
||||
|
|
@ -84,12 +85,12 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
|||
{stateLabels[app.state]}
|
||||
</InfoValue>
|
||||
</InfoRow>
|
||||
{app.state === 'running' && app.port && (
|
||||
{app.state === 'running' && (
|
||||
<InfoRow>
|
||||
<InfoLabel>URL</InfoLabel>
|
||||
<InfoValue>
|
||||
<Link href={`${location.protocol}//${location.hostname}:${app.port}`} target="_blank">
|
||||
{location.protocol}//{location.hostname}:{app.port}
|
||||
<Link href={buildAppUrl(app.name, location.origin)} target="_blank">
|
||||
{buildAppUrl(app.name, location.origin)}
|
||||
</Link>
|
||||
</InfoValue>
|
||||
</InfoRow>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function initToolIframes() {
|
|||
`
|
||||
}
|
||||
|
||||
// Build URL with params using TOES_URL base
|
||||
// Build URL with params using port-based routing
|
||||
function buildToolUrl(port: number, params: Record<string, string>): string {
|
||||
const base = new URL(window.location.origin)
|
||||
base.port = String(port)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
|
||||
import { disableTunnel, enableTunnel, isTunnelsAvailable } from '../tunnels'
|
||||
import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels'
|
||||
import type { App as BackendApp } from '$apps'
|
||||
import type { App as SharedApp } from '@types'
|
||||
import { generateTemplates, type TemplateType } from '%templates'
|
||||
|
|
@ -398,7 +398,7 @@ router.post('/:app/tunnel', c => {
|
|||
|
||||
if (app.state !== 'running') return c.json({ ok: false, error: 'App must be running to enable tunnel' }, 400)
|
||||
|
||||
enableTunnel(appName, app.port ?? 0)
|
||||
shareApp(appName, app.port ?? 0)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
|
|
@ -409,7 +409,7 @@ router.delete('/:app/tunnel', c => {
|
|||
const app = allApps().find(a => a.name === appName)
|
||||
if (!app) return c.json({ error: 'App not found' }, 404)
|
||||
|
||||
disableTunnel(appName)
|
||||
unshareApp(appName)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ 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 { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { hostname } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import { loadAppEnv } from '../tools/env'
|
||||
import { disableTunnel, openTunnelIfEnabled, renameTunnelConfig } from './tunnels'
|
||||
import { publishApp, unpublishAll, unpublishApp } from './mdns'
|
||||
import { closeAllTunnels, closeTunnel, openTunnelIfEnabled, renameTunnelConfig, unshareApp } from './tunnels'
|
||||
import { appLog, hostLog, setApps } from './tui'
|
||||
|
||||
export type { AppState } from '@types'
|
||||
|
|
@ -129,7 +131,8 @@ export function removeApp(dir: string) {
|
|||
const app = _apps.get(dir)
|
||||
if (!app) return
|
||||
|
||||
disableTunnel(dir)
|
||||
unpublishApp(dir)
|
||||
unshareApp(dir)
|
||||
|
||||
// Clear all timers
|
||||
clearTimers(app)
|
||||
|
|
@ -420,6 +423,8 @@ function getPort(appName?: string): number {
|
|||
async function gracefulShutdown(signal: string) {
|
||||
if (_shuttingDown) return
|
||||
_shuttingDown = true
|
||||
unpublishAll()
|
||||
closeAllTunnels()
|
||||
|
||||
hostLog(`Received ${signal}, shutting down gracefully...`)
|
||||
|
||||
|
|
@ -521,6 +526,7 @@ function markAsRunning(app: App, port: number, isHttpApp: boolean) {
|
|||
app.isHttpApp = isHttpApp
|
||||
update()
|
||||
emit({ type: 'app:start', app: app.name })
|
||||
publishApp(app.name)
|
||||
openTunnelIfEnabled(app.name, port)
|
||||
|
||||
if (isHttpApp) {
|
||||
|
|
@ -632,7 +638,7 @@ async function runApp(dir: string, port: number) {
|
|||
|
||||
const proc = Bun.spawn(['bun', 'run', 'toes'], {
|
||||
cwd,
|
||||
env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', 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: join(process.env.DATA_DIR ?? '.', 'toes', dir), TOES_DIR, TOES_URL },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
|
@ -736,6 +742,9 @@ async function runApp(dir: string, port: number) {
|
|||
writeLogLine(dir, 'system', 'Stopped')
|
||||
}
|
||||
|
||||
unpublishApp(dir)
|
||||
closeTunnel(dir)
|
||||
|
||||
// Release port back to pool
|
||||
if (app.port) {
|
||||
releasePort(app.port)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { allApps, initApps, TOES_URL } from '$apps'
|
||||
import { buildAppUrl } from '@urls'
|
||||
import appsRouter from './api/apps'
|
||||
import eventsRouter from './api/events'
|
||||
import syncRouter from './api/sync'
|
||||
import systemRouter from './api/system'
|
||||
import { Hype } from '@because/hype'
|
||||
import { cleanupStalePublishers } from './mdns'
|
||||
import { extractSubdomain, proxySubdomain, proxyWebSocket, websocket } from './proxy'
|
||||
|
||||
const app = new Hype({ layout: false, logging: !!process.env.DEBUG })
|
||||
|
||||
|
|
@ -12,7 +15,7 @@ app.route('/api/events', eventsRouter)
|
|||
app.route('/api/sync', syncRouter)
|
||||
app.route('/api/system', systemRouter)
|
||||
|
||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port
|
||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool subdomain
|
||||
app.get('/tool/:tool', c => {
|
||||
const toolName = c.req.param('tool')
|
||||
const tool = allApps().find(a => a.tool && a.name === toolName)
|
||||
|
|
@ -20,9 +23,8 @@ app.get('/tool/:tool', c => {
|
|||
return c.text(`Tool "${toolName}" not found or not running`, 404)
|
||||
}
|
||||
const params = new URLSearchParams(c.req.query()).toString()
|
||||
const base = new URL(TOES_URL)
|
||||
base.port = String(tool.port)
|
||||
const url = params ? `${base.origin}?${params}` : base.origin
|
||||
const base = buildAppUrl(toolName, TOES_URL)
|
||||
const url = params ? `${base}?${params}` : base
|
||||
return c.redirect(url)
|
||||
})
|
||||
|
||||
|
|
@ -55,9 +57,23 @@ app.all('/api/tools/:tool/:path{.+}', async c => {
|
|||
})
|
||||
})
|
||||
|
||||
cleanupStalePublishers()
|
||||
await initApps()
|
||||
|
||||
const defaults = app.defaults
|
||||
|
||||
export default {
|
||||
...app.defaults,
|
||||
...defaults,
|
||||
maxRequestBodySize: 1024 * 1024 * 50, // 50MB
|
||||
fetch(req: Request, server: any) {
|
||||
const subdomain = extractSubdomain(req.headers.get('host') ?? '')
|
||||
if (subdomain) {
|
||||
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
||||
return proxyWebSocket(subdomain, req, server)
|
||||
}
|
||||
return proxySubdomain(subdomain, req)
|
||||
}
|
||||
return defaults.fetch.call(app, req, server)
|
||||
},
|
||||
websocket,
|
||||
}
|
||||
|
|
|
|||
81
src/server/mdns.ts
Normal file
81
src/server/mdns.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { Subprocess } from 'bun'
|
||||
import { networkInterfaces } from 'os'
|
||||
import { hostLog } from './tui'
|
||||
|
||||
const _publishers = new Map<string, Subprocess>()
|
||||
|
||||
const isEnabled = process.env.NODE_ENV === 'production' && process.platform === 'linux'
|
||||
|
||||
function getLocalIp(): string | null {
|
||||
const interfaces = networkInterfaces()
|
||||
for (const iface of Object.values(interfaces)) {
|
||||
if (!iface) continue
|
||||
for (const addr of iface) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
return addr.address
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function cleanupStalePublishers() {
|
||||
if (!isEnabled) return
|
||||
|
||||
try {
|
||||
const result = Bun.spawnSync(['pkill', '-f', 'avahi-publish.*toes\\.local'])
|
||||
if (result.exitCode === 0) {
|
||||
hostLog('mDNS: cleaned up stale avahi-publish processes')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function publishApp(name: string) {
|
||||
if (!isEnabled) return
|
||||
if (_publishers.has(name)) return
|
||||
|
||||
const ip = getLocalIp()
|
||||
if (!ip) {
|
||||
hostLog(`mDNS: no local IP found, skipping ${name}`)
|
||||
return
|
||||
}
|
||||
|
||||
const hostname = `${name}.toes.local`
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(['avahi-publish', '-a', hostname, '-R', ip], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
|
||||
_publishers.set(name, proc)
|
||||
hostLog(`mDNS: published ${hostname} -> ${ip}`)
|
||||
|
||||
proc.exited.then(() => {
|
||||
_publishers.delete(name)
|
||||
})
|
||||
} catch {
|
||||
hostLog(`mDNS: failed to publish ${hostname}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function unpublishApp(name: string) {
|
||||
if (!isEnabled) return
|
||||
|
||||
const proc = _publishers.get(name)
|
||||
if (!proc) return
|
||||
|
||||
proc.kill()
|
||||
_publishers.delete(name)
|
||||
hostLog(`mDNS: unpublished ${name}.toes.local`)
|
||||
}
|
||||
|
||||
export function unpublishAll() {
|
||||
if (!isEnabled) return
|
||||
|
||||
for (const [name, proc] of _publishers) {
|
||||
proc.kill()
|
||||
hostLog(`mDNS: unpublished ${name}.toes.local`)
|
||||
}
|
||||
_publishers.clear()
|
||||
}
|
||||
50
src/server/proxy.test.ts
Normal file
50
src/server/proxy.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { extractSubdomain } from './proxy'
|
||||
|
||||
describe('extractSubdomain', () => {
|
||||
describe('*.localhost (dev)', () => {
|
||||
test('should extract subdomain from clock.localhost:3000', () => {
|
||||
expect(extractSubdomain('clock.localhost:3000')).toBe('clock')
|
||||
})
|
||||
|
||||
test('should extract subdomain from clock.localhost', () => {
|
||||
expect(extractSubdomain('clock.localhost')).toBe('clock')
|
||||
})
|
||||
|
||||
test('should return null for bare localhost:3000', () => {
|
||||
expect(extractSubdomain('localhost:3000')).toBeNull()
|
||||
})
|
||||
|
||||
test('should return null for bare localhost', () => {
|
||||
expect(extractSubdomain('localhost')).toBeNull()
|
||||
})
|
||||
|
||||
test('should handle hyphenated app names', () => {
|
||||
expect(extractSubdomain('my-app.localhost:3000')).toBe('my-app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('*.toes.local (production)', () => {
|
||||
test('should extract subdomain from clock.toes.local', () => {
|
||||
expect(extractSubdomain('clock.toes.local')).toBe('clock')
|
||||
})
|
||||
|
||||
test('should return null for bare toes.local', () => {
|
||||
expect(extractSubdomain('toes.local')).toBeNull()
|
||||
})
|
||||
|
||||
test('should handle hyphenated app names', () => {
|
||||
expect(extractSubdomain('my-app.toes.local')).toBe('my-app')
|
||||
})
|
||||
})
|
||||
|
||||
describe('other hosts', () => {
|
||||
test('should return null for plain IP', () => {
|
||||
expect(extractSubdomain('192.168.1.50:3000')).toBeNull()
|
||||
})
|
||||
|
||||
test('should return null for empty string', () => {
|
||||
expect(extractSubdomain('')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
src/server/proxy.ts
Normal file
105
src/server/proxy.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { ServerWebSocket } from 'bun'
|
||||
import { getApp } from '$apps'
|
||||
|
||||
const upstreams = new Map<ServerWebSocket<WsData>, WebSocket>()
|
||||
|
||||
interface WsData {
|
||||
port: number
|
||||
path: string
|
||||
}
|
||||
|
||||
export function extractSubdomain(host: string): string | null {
|
||||
// Strip port
|
||||
const hostname = host.replace(/:\d+$/, '')
|
||||
|
||||
// *.localhost -> take prefix (e.g. clock.localhost -> clock)
|
||||
if (hostname.endsWith('.localhost')) {
|
||||
const sub = hostname.slice(0, -'.localhost'.length)
|
||||
return sub || null
|
||||
}
|
||||
|
||||
// *.X.local -> take first segment if 3+ parts (e.g. clock.toes.local -> clock)
|
||||
if (hostname.endsWith('.local')) {
|
||||
const parts = hostname.split('.')
|
||||
if (parts.length >= 3) {
|
||||
return parts[0]!
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function proxySubdomain(subdomain: string, req: Request): Promise<Response> {
|
||||
const app = getApp(subdomain)
|
||||
|
||||
if (!app || app.state !== 'running' || !app.port) {
|
||||
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
|
||||
}
|
||||
|
||||
const url = new URL(req.url)
|
||||
const target = `http://localhost:${app.port}${url.pathname}${url.search}`
|
||||
|
||||
try {
|
||||
return await fetch(target, {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? req.body : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(`Proxy error for ${subdomain}:`, e)
|
||||
return new Response(`App "${subdomain}" is not responding`, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export function proxyWebSocket(subdomain: string, req: Request, server: any): Response | undefined {
|
||||
const app = getApp(subdomain)
|
||||
|
||||
if (!app || app.state !== 'running' || !app.port) {
|
||||
return new Response(`App "${subdomain}" not found or not running`, { status: 502 })
|
||||
}
|
||||
|
||||
const url = new URL(req.url)
|
||||
const path = url.pathname + url.search
|
||||
|
||||
const ok = server.upgrade(req, { data: { port: app.port, path } as WsData })
|
||||
if (ok) return undefined
|
||||
return new Response('WebSocket upgrade failed', { status: 500 })
|
||||
}
|
||||
|
||||
export const websocket = {
|
||||
open(ws: ServerWebSocket<WsData>) {
|
||||
const { port, path } = ws.data
|
||||
const upstream = new WebSocket(`ws://localhost:${port}${path}`)
|
||||
|
||||
upstream.binaryType = 'arraybuffer'
|
||||
upstreams.set(ws, upstream)
|
||||
|
||||
upstream.addEventListener('message', e => {
|
||||
ws.send(e.data as string | ArrayBuffer)
|
||||
})
|
||||
|
||||
upstream.addEventListener('close', () => {
|
||||
upstreams.delete(ws)
|
||||
ws.close()
|
||||
})
|
||||
|
||||
upstream.addEventListener('error', () => {
|
||||
upstreams.delete(ws)
|
||||
ws.close()
|
||||
})
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WsData>, msg: string | ArrayBuffer | Uint8Array) {
|
||||
const upstream = upstreams.get(ws)
|
||||
if (!upstream || upstream.readyState !== WebSocket.OPEN) return
|
||||
upstream.send(msg)
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WsData>) {
|
||||
const upstream = upstreams.get(ws)
|
||||
if (upstream) {
|
||||
upstream.close()
|
||||
upstreams.delete(ws)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ export function closeTunnel(appName: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function disableTunnel(appName: string) {
|
||||
export function unshareApp(appName: string) {
|
||||
closeTunnel(appName)
|
||||
|
||||
const config = loadConfig()
|
||||
|
|
@ -99,7 +99,7 @@ export function disableTunnel(appName: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function enableTunnel(appName: string, port: number) {
|
||||
export function shareApp(appName: string, port: number) {
|
||||
if (!SNEAKER_URL) return
|
||||
|
||||
const app = getApp(appName)
|
||||
|
|
@ -200,7 +200,7 @@ function openTunnel(appName: string, port: number, subdomain?: string) {
|
|||
_tunnels.delete(appName)
|
||||
_tunnelPorts.delete(appName)
|
||||
|
||||
// Intentional close (disableTunnel, closeAllTunnels, etc.) — don't reconnect
|
||||
// Intentional close (unshareApp, closeAllTunnels, etc.) — don't reconnect
|
||||
if (_closing.delete(appName)) {
|
||||
hostLog(`Tunnel closed: ${appName}`)
|
||||
return
|
||||
|
|
|
|||
5
src/shared/urls.ts
Normal file
5
src/shared/urls.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function buildAppUrl(appName: string, baseUrl: string): string {
|
||||
const url = new URL(baseUrl)
|
||||
url.hostname = `${appName}.${url.hostname}`
|
||||
return url.origin
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user