Compare commits
18 Commits
9db13eaa6a
...
894ae7e497
| Author | SHA1 | Date | |
|---|---|---|---|
| 894ae7e497 | |||
| 21e300df90 | |||
| 267e4e59f7 | |||
| 946cdb1794 | |||
| 0e943bda2a | |||
| eef2fabd71 | |||
| b410a74d15 | |||
| d9533032bc | |||
| e0347444aa | |||
| 423c9588da | |||
| ecae0b4a5c | |||
| f16201114e | |||
| 758ad67fd4 | |||
| 711a9db55e | |||
| 5954959208 | |||
| 0aa375f037 | |||
| 98c09dd843 | |||
| 26189e9e4d |
|
|
@ -7,7 +7,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/toes": "0.0.9",
|
"@because/toes": "^0.0.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|
@ -22,15 +22,15 @@
|
||||||
|
|
||||||
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
"@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
|
||||||
|
|
||||||
"@because/sneaker": ["@because/sneaker@0.0.3", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.3.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-4cG8w/tYPGbDtLw89k1PiASJKfWUdd1NXv+GKad2d7Ckw3FpZ+dnN2+gR2ihs81dqAkNaZomo+9RznBju2WaOw=="],
|
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
|
||||||
|
|
||||||
"@because/toes": ["@because/toes@0.0.9", "https://npm.nose.space/@because/toes/-/toes-0.0.9.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.3", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-QkRTPLkPW8awH1DC0vqFR9oS3q+xOZ00eL6VuFElM1fatqHnLO8zKKJcyvy1pU8ZKS4Ev7F+OoyBzRuo6OTa/g=="],
|
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
"@types/node": ["@types/node@25.3.3", "https://npm.nose.space/@types/node/-/node-25.3.3.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
|
|
||||||
2
apps/git/bunfig.toml
Normal file
2
apps/git/bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[install]
|
||||||
|
registry = "https://npm.nose.space"
|
||||||
|
|
@ -15,26 +15,6 @@ const TOES_URL = process.env.TOES_URL!
|
||||||
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
|
const REPOS_DIR = resolve(DATA_ROOT, 'repos')
|
||||||
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
|
const VISIBILITY_PATH = join(DATA_DIR, 'visibility.json')
|
||||||
|
|
||||||
const TOGGLE_SCRIPT = `
|
|
||||||
function toggleVisibility(btn) {
|
|
||||||
var repo = btn.dataset.repo;
|
|
||||||
var current = btn.dataset.visibility;
|
|
||||||
var next = current === 'public' ? 'private' : 'public';
|
|
||||||
btn.dataset.visibility = next;
|
|
||||||
btn.textContent = next;
|
|
||||||
btn.classList.toggle('public', next === 'public');
|
|
||||||
fetch('/api/visibility/' + encodeURIComponent(repo), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ visibility: next })
|
|
||||||
}).catch(function() {
|
|
||||||
btn.dataset.visibility = current;
|
|
||||||
btn.textContent = current;
|
|
||||||
btn.classList.toggle('public', current === 'public');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const app = new Hype({ prettyHTML: false, layout: false })
|
const app = new Hype({ prettyHTML: false, layout: false })
|
||||||
const deployLocks = new Map<string, Promise<void>>()
|
const deployLocks = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
|
@ -113,6 +93,31 @@ const RepoName = define('RepoName', {
|
||||||
color: theme('colors-text'),
|
color: theme('colors-text'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const Tab = define('Tab', {
|
||||||
|
base: 'button',
|
||||||
|
padding: '6px 0',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '2px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
states: {
|
||||||
|
':hover': { color: theme('colors-text') },
|
||||||
|
'.active': {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
borderBottomColor: theme('colors-primary'),
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TabBar = define('TabBar', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '24px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
})
|
||||||
|
|
||||||
const Toggle = define('Toggle', {
|
const Toggle = define('Toggle', {
|
||||||
base: 'button',
|
base: 'button',
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
|
|
@ -156,7 +161,8 @@ interface LayoutProps {
|
||||||
interface RepoListPageProps {
|
interface RepoListPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
external: boolean
|
external: boolean
|
||||||
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility }>
|
repos: Array<{ name: string; commits: boolean; branch: string; visibility: Visibility; tool: boolean }>
|
||||||
|
tunnelUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Visibility = 'public' | 'private'
|
type Visibility = 'public' | 'private'
|
||||||
|
|
@ -543,7 +549,56 @@ function AppRepo({ appName, baseUrl, branch, exists, commits }: AppRepoProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
|
function RepoListItems({ baseUrl, external, repos, tunnelUrl }: {
|
||||||
|
baseUrl: string
|
||||||
|
external: boolean
|
||||||
|
repos: RepoListPageProps['repos']
|
||||||
|
tunnelUrl?: string
|
||||||
|
}) {
|
||||||
|
if (repos.length === 0) {
|
||||||
|
return <HelpText>No repositories yet.</HelpText>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RepoList>
|
||||||
|
{repos.map(({ name, commits, branch, visibility }) => (
|
||||||
|
<RepoItem>
|
||||||
|
<div>
|
||||||
|
<RepoName>{name}</RepoName>
|
||||||
|
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
||||||
|
git clone {baseUrl}/{name}
|
||||||
|
</HelpText>
|
||||||
|
{!external && tunnelUrl && visibility === 'public' && (
|
||||||
|
<HelpText style="margin: 2px 0 0; font-size: 12px">
|
||||||
|
git clone {tunnelUrl}/{name}
|
||||||
|
</HelpText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center">
|
||||||
|
{!external && (
|
||||||
|
<Toggle
|
||||||
|
class={visibility === 'public' ? 'public' : ''}
|
||||||
|
data-repo={name}
|
||||||
|
data-visibility={visibility}
|
||||||
|
onclick="toggleVisibility(this)"
|
||||||
|
>
|
||||||
|
{visibility === 'public' ? 'public' : 'private'}
|
||||||
|
</Toggle>
|
||||||
|
)}
|
||||||
|
<Badge>{branch}</Badge>
|
||||||
|
{commits
|
||||||
|
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
||||||
|
: <Badge>empty</Badge>}
|
||||||
|
</div>
|
||||||
|
</RepoItem>
|
||||||
|
))}
|
||||||
|
</RepoList>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RepoListPage({ baseUrl, external, repos, tunnelUrl }: RepoListPageProps) {
|
||||||
|
const appRepos = repos.filter(r => !r.tool)
|
||||||
|
const toolRepos = repos.filter(r => r.tool)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Git">
|
<Layout title="Git">
|
||||||
{!external && (
|
{!external && (
|
||||||
|
|
@ -565,38 +620,31 @@ function RepoListPage({ baseUrl, external, repos }: RepoListPageProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{repos.length > 0 && (
|
{repos.length > 0 && appRepos.length > 0 && toolRepos.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Heading>Repositories</Heading>
|
<Heading>Repositories</Heading>
|
||||||
<RepoList>
|
<TabBar>
|
||||||
{repos.map(({ name, commits, branch, visibility }) => (
|
<Tab class="active" data-tab="tab-apps" onclick="switchTab(this)">Apps</Tab>
|
||||||
<RepoItem>
|
<Tab data-tab="tab-tools" onclick="switchTab(this)">Tools</Tab>
|
||||||
<div>
|
</TabBar>
|
||||||
<RepoName>{name}</RepoName>
|
<div>
|
||||||
<HelpText style="margin: 4px 0 0; font-size: 12px">
|
<div id="tab-apps">
|
||||||
git clone {baseUrl}/{name}
|
<RepoListItems baseUrl={baseUrl} external={external} repos={appRepos} tunnelUrl={tunnelUrl} />
|
||||||
</HelpText>
|
</div>
|
||||||
</div>
|
<div id="tab-tools" style="display: none">
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
<RepoListItems baseUrl={baseUrl} external={external} repos={toolRepos} tunnelUrl={tunnelUrl} />
|
||||||
{!external && (
|
</div>
|
||||||
<Toggle
|
</div>
|
||||||
class={visibility === 'public' ? 'public' : ''}
|
{!external && <script src="/client/toggle.js" />}
|
||||||
data-repo={name}
|
<script src="/client/tabs.js" />
|
||||||
data-visibility={visibility}
|
</>
|
||||||
onclick="toggleVisibility(this)"
|
)}
|
||||||
>
|
|
||||||
{visibility === 'public' ? 'public' : 'private'}
|
{repos.length > 0 && (appRepos.length === 0 || toolRepos.length === 0) && (
|
||||||
</Toggle>
|
<>
|
||||||
)}
|
<Heading>Repositories</Heading>
|
||||||
<Badge>{branch}</Badge>
|
<RepoListItems baseUrl={baseUrl} external={external} repos={repos} tunnelUrl={tunnelUrl} />
|
||||||
{commits
|
{!external && <script src="/client/toggle.js" />}
|
||||||
? <Badge style={`color: ${theme('colors-statusRunning')}`}>deployed</Badge>
|
|
||||||
: <Badge>empty</Badge>}
|
|
||||||
</div>
|
|
||||||
</RepoItem>
|
|
||||||
))}
|
|
||||||
</RepoList>
|
|
||||||
{!external && <script src="/toggle.js" />}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -624,9 +672,6 @@ app.get('/styles.css', c =>
|
||||||
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
|
c.text(baseStyles + stylesToCSS(), 200, { 'Content-Type': 'text/css; charset=utf-8' }),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.get('/toggle.js', c =>
|
|
||||||
c.text(TOGGLE_SCRIPT, 200, { 'Content-Type': 'application/javascript; charset=utf-8' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
|
// GET /:repo[.git]/info/refs?service=git-upload-pack|git-receive-pack
|
||||||
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
|
app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
|
||||||
|
|
@ -641,8 +686,13 @@ app.on('GET', ['/:repo{.+\\.git}/info/refs', '/:repo/info/refs'], async c => {
|
||||||
return c.text('Invalid service', 400)
|
return c.text('Invalid service', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (service === 'git-receive-pack' && c.req.header('x-sneaker')) {
|
if (c.req.header('x-sneaker')) {
|
||||||
return c.text('Push access denied over sneaker', 403)
|
if (service === 'git-receive-pack') {
|
||||||
|
return c.text('Push access denied over sneaker', 403)
|
||||||
|
}
|
||||||
|
if (await getVisibility(repoParam) !== 'public') {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (service === 'git-receive-pack') {
|
if (service === 'git-receive-pack') {
|
||||||
|
|
@ -666,6 +716,10 @@ app.on('POST', ['/:repo{.+\\.git}/git-upload-pack', '/:repo/git-upload-pack'], a
|
||||||
return c.text('Invalid repository name', 400)
|
return c.text('Invalid repository name', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (c.req.header('x-sneaker') && await getVisibility(repoParam) !== 'public') {
|
||||||
|
return c.text('Repository not found', 404)
|
||||||
|
}
|
||||||
|
|
||||||
const bare = repoPath(repoParam)
|
const bare = repoPath(repoParam)
|
||||||
if (!(await dirExists(bare))) {
|
if (!(await dirExists(bare))) {
|
||||||
return c.text('Repository not found', 404)
|
return c.text('Repository not found', 404)
|
||||||
|
|
@ -748,8 +802,9 @@ app.post('/api/visibility/:repo', async c => {
|
||||||
|
|
||||||
app.get('/', async c => {
|
app.get('/', async c => {
|
||||||
const appName = c.req.query('app')
|
const appName = c.req.query('app')
|
||||||
const baseUrl = APP_URL
|
const sneakerHost = c.req.header('x-sneaker')
|
||||||
const external = !!c.req.header('x-sneaker')
|
const external = !!sneakerHost
|
||||||
|
const baseUrl = sneakerHost ? `https://${sneakerHost}` : APP_URL
|
||||||
|
|
||||||
// When viewing a specific app, only show that app's repo
|
// When viewing a specific app, only show that app's repo
|
||||||
if (appName) {
|
if (appName) {
|
||||||
|
|
@ -764,6 +819,19 @@ app.get('/', async c => {
|
||||||
|
|
||||||
// No app selected — show all repos
|
// No app selected — show all repos
|
||||||
const repos = await listRepos()
|
const repos = await listRepos()
|
||||||
|
|
||||||
|
// Fetch all apps to determine which repos are tools
|
||||||
|
let toolSet = new Set<string>()
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${TOES_URL}/api/apps`)
|
||||||
|
if (res.ok) {
|
||||||
|
const apps = await res.json() as Array<{ name: string; tool?: boolean | string }>
|
||||||
|
for (const a of apps) {
|
||||||
|
if (a.tool) toolSet.add(a.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const repoData = await Promise.all(repos.map(async name => {
|
const repoData = await Promise.all(repos.map(async name => {
|
||||||
const bare = repoPath(name)
|
const bare = repoPath(name)
|
||||||
const [commits, branch, visibility] = await Promise.all([
|
const [commits, branch, visibility] = await Promise.all([
|
||||||
|
|
@ -771,7 +839,7 @@ app.get('/', async c => {
|
||||||
getDefaultBranch(bare),
|
getDefaultBranch(bare),
|
||||||
getVisibility(name),
|
getVisibility(name),
|
||||||
])
|
])
|
||||||
return { name, commits, branch, visibility }
|
return { name, commits, branch, visibility, tool: toolSet.has(name) }
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Hide private repos from external (sneaker) requests
|
// Hide private repos from external (sneaker) requests
|
||||||
|
|
@ -779,7 +847,19 @@ app.get('/', async c => {
|
||||||
? repoData.filter(r => r.visibility === 'public')
|
? repoData.filter(r => r.visibility === 'public')
|
||||||
: repoData
|
: repoData
|
||||||
|
|
||||||
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} />)
|
// Fetch tunnel URL for the git tool so we can show it for public repos
|
||||||
|
let tunnelUrl: string | undefined
|
||||||
|
if (!external) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${TOES_URL}/api/apps/git`)
|
||||||
|
if (res.ok) {
|
||||||
|
const info = await res.json() as { tunnelUrl?: string }
|
||||||
|
tunnelUrl = info.tunnelUrl
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(<RepoListPage baseUrl={baseUrl} external={external} repos={filtered} tunnelUrl={tunnelUrl} />)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default app.defaults
|
export default app.defaults
|
||||||
|
|
|
||||||
13
apps/git/src/client/tabs.ts
Normal file
13
apps/git/src/client/tabs.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
function switchTab(btn: HTMLButtonElement) {
|
||||||
|
const tabs = btn.parentElement!.querySelectorAll('button')
|
||||||
|
for (const tab of tabs) tab.classList.remove('active')
|
||||||
|
btn.classList.add('active')
|
||||||
|
|
||||||
|
const panels = btn.parentElement!.nextElementSibling!.children
|
||||||
|
for (const panel of panels) (panel as HTMLElement).style.display = 'none'
|
||||||
|
|
||||||
|
const target = document.getElementById(btn.dataset.tab!)
|
||||||
|
if (target) target.style.display = 'block'
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { switchTab })
|
||||||
19
apps/git/src/client/toggle.ts
Normal file
19
apps/git/src/client/toggle.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
function toggleVisibility(btn: HTMLButtonElement) {
|
||||||
|
const repo = btn.dataset.repo!
|
||||||
|
const current = btn.dataset.visibility!
|
||||||
|
const next = current === 'public' ? 'private' : 'public'
|
||||||
|
btn.dataset.visibility = next
|
||||||
|
btn.textContent = next
|
||||||
|
btn.classList.toggle('public', next === 'public')
|
||||||
|
fetch('/api/visibility/' + encodeURIComponent(repo), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ visibility: next }),
|
||||||
|
}).catch(() => {
|
||||||
|
btn.dataset.visibility = current
|
||||||
|
btn.textContent = current
|
||||||
|
btn.classList.toggle('public', current === 'public')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { toggleVisibility })
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -8,6 +8,7 @@
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/sneaker": "^0.0.4",
|
"@because/sneaker": "^0.0.4",
|
||||||
|
"@because/toes": "^0.0.12",
|
||||||
"commander": "14.0.3",
|
"commander": "14.0.3",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
|
|
@ -28,6 +29,8 @@
|
||||||
|
|
||||||
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
|
"@because/sneaker": ["@because/sneaker@0.0.4", "https://npm.nose.space/@because/sneaker/-/sneaker-0.0.4.tgz", { "dependencies": { "hono": "^4.9.8", "unique-names-generator": "^4.7.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-juklirqLPOzCQTlY3Vf6elXO7bPTEfc1QB4ephdWONZwllovtAEF4H0O6CoOcoV5g5P0i8qUu+ffNVqtkC3SBw=="],
|
||||||
|
|
||||||
|
"@because/toes": ["@because/toes@0.0.12", "https://npm.nose.space/@because/toes/-/toes-0.0.12.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.4", "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.3" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-jJu2hU/QmFZ2mNQZg6Z/gqbRUU4twMn+jPIijk7+UMzU4spbUa4pmNkr+zVlBPo38Sx7edHxf3F0SAjoYkEbaQ=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||||
|
|
||||||
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
|
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
|
||||||
|
|
|
||||||
45
color-preview.sh
Executable file
45
color-preview.sh
Executable file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " ┌── CLI installer (curl | bash) ──────────────┐"
|
||||||
|
echo ""
|
||||||
|
echo " ${b}🐾 toes cli${r}"
|
||||||
|
echo " ${d}─────────────────────────────${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}Fetching macos/arm64...${r}"
|
||||||
|
echo " ${g}Installed to${r} ${b}/Users/chris/.local/bin/toes${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${y}Add /Users/chris/.local/bin to your \$PATH, then:${r}"
|
||||||
|
echo " Run ${c}toes${r} to get started."
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
echo " ┌── After deploy ─────────────────────────────┐"
|
||||||
|
echo ""
|
||||||
|
echo " ${b}${g}🐾 Deployed${r} to ${b}pi@toes.local${r}"
|
||||||
|
echo " ${d}─────────────────────────────${r}"
|
||||||
|
echo ""
|
||||||
|
echo " Dashboard: ${c}http://toes.local${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}Grab the CLI:${r}"
|
||||||
|
echo " ${c}curl -fsSL http://toes.local/install | bash${r}"
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
echo " ┌── After server install ─────────────────────┐"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}╔══════════════════════════════════╗${r}"
|
||||||
|
echo " ${d}║${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
|
||||||
|
echo " ${d}╚══════════════════════════════════╝${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}>>${r} Updating system packages"
|
||||||
|
echo " ${d}>>${r} Installing bun"
|
||||||
|
echo " ${d}>>${r} Building"
|
||||||
|
echo ""
|
||||||
|
echo " ${b}${g}🐾 toes abc1234 is up!${r}"
|
||||||
|
echo " ${d}─────────────────────────────${r}"
|
||||||
|
echo ""
|
||||||
|
echo " Dashboard: ${c}http://toes.local${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}Grab the CLI:${r}"
|
||||||
|
echo " ${c}curl -fsSL http://toes.local/install | bash${r}"
|
||||||
|
echo ""
|
||||||
|
|
@ -15,18 +15,20 @@ DATA_DIR=~/data
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
|
||||||
|
|
||||||
quiet() { "$@" > /dev/null 2>&1; }
|
quiet() { "$@" > /dev/null 2>&1; }
|
||||||
|
|
||||||
info() { echo ">> $1"; }
|
info() { echo " ${d}>>${r} $1"; }
|
||||||
|
|
||||||
fail() { echo "ERROR: $1" >&2; exit 1; }
|
fail() { echo " ${y}ERROR:${r} $1" >&2; exit 1; }
|
||||||
|
|
||||||
# ── Preflight ────────────────────────────────────────────
|
# ── Preflight ────────────────────────────────────────────
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " ╔══════════════════════════════════╗"
|
echo " ${d}╔══════════════════════════════════╗${r}"
|
||||||
echo " ║ 🐾 toes - personal web appliance ║"
|
echo " ${d}║${r} ${b}🐾 toes${r} ${d}- personal web appliance ║${r}"
|
||||||
echo " ╚══════════════════════════════════╝"
|
echo " ${d}╚══════════════════════════════════╝${r}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
|
[ "$(whoami)" = "toes" ] || fail "Must be run as the 'toes' user."
|
||||||
|
|
@ -125,9 +127,11 @@ sudo systemctl restart toes
|
||||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
|
VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " toes $VERSION is running!"
|
echo " ${b}${g}🐾 toes $VERSION is up!${r}"
|
||||||
echo " http://$(hostname).local"
|
echo " ${d}─────────────────────────────${r}"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Install the CLI on your local machine:"
|
echo " Dashboard: ${c}http://$(hostname).local${r}"
|
||||||
echo " curl -fsSL http://$(hostname).local/install | bash"
|
echo ""
|
||||||
|
echo " ${d}Grab the CLI:${r}"
|
||||||
|
echo " ${c}curl -fsSL http://$(hostname).local/install | bash${r}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/toes",
|
"name": "@because/toes",
|
||||||
"version": "0.0.10",
|
"version": "0.0.12",
|
||||||
"description": "personal web appliance - turn it on and forget about the cloud",
|
"description": "personal web appliance - turn it on and forget about the cloud",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.2",
|
"@because/hype": "^0.0.2",
|
||||||
"@because/sneaker": "^0.0.4",
|
"@because/sneaker": "^0.0.4",
|
||||||
|
"@because/toes": "^0.0.12",
|
||||||
"commander": "14.0.3",
|
"commander": "14.0.3",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,14 @@ done
|
||||||
sudo systemctl restart toes.service
|
sudo systemctl restart toes.service
|
||||||
SCRIPT
|
SCRIPT
|
||||||
|
|
||||||
echo "=> Deployed to $SSH_HOST"
|
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' r=$'\033[0m'
|
||||||
echo "=> Visit $URL"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Install the CLI on your local machine:"
|
echo " ${b}${g}🐾 Deployed${r} to ${b}$SSH_HOST${r}"
|
||||||
echo " curl -fsSL $URL/install | bash"
|
echo " ${d}─────────────────────────────${r}"
|
||||||
|
echo ""
|
||||||
|
echo " Dashboard: ${c}$URL${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}Grab the CLI:${r}"
|
||||||
|
echo " ${c}curl -fsSL $URL/install | bash${r}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
<ClickableAppName onClick={() => openRenameAppModal(app)}>{app.name}</ClickableAppName>
|
||||||
</MainTitle>
|
</MainTitle>
|
||||||
<HeaderActions>
|
<HeaderActions>
|
||||||
{!app.tool && (
|
{(!app.tool || app.share) && (
|
||||||
app.tunnelUrl
|
app.tunnelUrl
|
||||||
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
|
? <Button onClick={() => { unshareApp(app.name) }}>Unshare</Button>
|
||||||
: app.tunnelEnabled
|
: app.tunnelEnabled
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,9 @@ function convert(app: BackendApp): SharedApp {
|
||||||
router.sse('/stream', (send) => {
|
router.sse('/stream', (send) => {
|
||||||
let queue = Promise.resolve()
|
let queue = Promise.resolve()
|
||||||
const broadcast = () => {
|
const broadcast = () => {
|
||||||
const apps: SharedApp[] = allApps().map(({
|
const apps: SharedApp[] = allApps().map(app => ({
|
||||||
name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl
|
...convert(app),
|
||||||
}) => ({
|
logs: app.logs,
|
||||||
name, state, icon, error, port, started, logs, tool, apps: apps_, dashboard, tunnelEnabled, tunnelUrl,
|
|
||||||
}))
|
}))
|
||||||
queue = queue.then(() => send(apps))
|
queue = queue.then(() => send(apps))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,11 +158,7 @@ export function registerApp(dir: string) {
|
||||||
|
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
|
_apps.set(dir, buildApp(dir, pkg, state, error))
|
||||||
const tool = pkg.toes?.tool
|
|
||||||
const apps = pkg.toes?.apps
|
|
||||||
const dashboard = pkg.toes?.dashboard
|
|
||||||
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
|
|
||||||
update()
|
update()
|
||||||
emit({ type: 'app:create', app: dir })
|
emit({ type: 'app:create', app: dir })
|
||||||
if (!error) {
|
if (!error) {
|
||||||
|
|
@ -301,6 +297,15 @@ export function updateAppIcon(dir: string, icon: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildApp = (dir: string, pkg: any, state: AppState, error?: string): App => ({
|
||||||
|
name: dir, state, error,
|
||||||
|
icon: pkg.toes?.icon ?? DEFAULT_EMOJI,
|
||||||
|
tool: pkg.toes?.tool,
|
||||||
|
apps: pkg.toes?.apps,
|
||||||
|
dashboard: pkg.toes?.dashboard,
|
||||||
|
share: pkg.toes?.share,
|
||||||
|
})
|
||||||
|
|
||||||
const clearTimers = (app: App) => {
|
const clearTimers = (app: App) => {
|
||||||
if (app.startupTimer) {
|
if (app.startupTimer) {
|
||||||
clearTimeout(app.startupTimer)
|
clearTimeout(app.startupTimer)
|
||||||
|
|
@ -349,11 +354,7 @@ 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 state: AppState = error ? 'invalid' : 'stopped'
|
||||||
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
|
_apps.set(dir, buildApp(dir, pkg, state, error))
|
||||||
const tool = pkg.toes?.tool
|
|
||||||
const apps = pkg.toes?.apps
|
|
||||||
const dashboard = pkg.toes?.dashboard
|
|
||||||
_apps.set(dir, { name: dir, state, icon, error, tool, apps, dashboard })
|
|
||||||
}
|
}
|
||||||
update()
|
update()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
b=$'\033[1m' d=$'\033[2m' g=$'\033[32m' c=$'\033[36m' y=$'\033[33m' r=$'\033[0m'
|
||||||
|
|
||||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
case "$OS" in
|
case "$OS" in
|
||||||
darwin) OS=macos ;;
|
darwin) OS=macos ;;
|
||||||
linux) OS=linux ;;
|
linux) OS=linux ;;
|
||||||
*) echo "🐾 Unsupported OS: $OS" >&2; exit 1 ;;
|
*) echo "${y}Unsupported OS: $OS${r}" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
x86_64) ARCH=x64 ;;
|
x86_64) ARCH=x64 ;;
|
||||||
arm64|aarch64) ARCH=arm64 ;;
|
arm64|aarch64) ARCH=arm64 ;;
|
||||||
*) echo "🐾 Unsupported arch: $ARCH" >&2; exit 1 ;;
|
*) echo "${y}Unsupported arch: $ARCH${r}" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
BINARY="toes-${OS}-${ARCH}"
|
BINARY="toes-${OS}-${ARCH}"
|
||||||
|
|
@ -28,11 +30,18 @@ else
|
||||||
fi
|
fi
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
echo "🐾 Downloading toes CLI (${OS}/${ARCH})..."
|
echo ""
|
||||||
|
echo " ${b}🐾 toes cli${r}"
|
||||||
|
echo " ${d}─────────────────────────────${r}"
|
||||||
|
echo ""
|
||||||
|
echo " ${d}Fetching ${OS}/${ARCH}...${r}"
|
||||||
curl -fsSL "$URL" -o "$INSTALL_DIR/toes"
|
curl -fsSL "$URL" -o "$INSTALL_DIR/toes"
|
||||||
chmod +x "$INSTALL_DIR/toes"
|
chmod +x "$INSTALL_DIR/toes"
|
||||||
echo "🐾 Installed toes to $INSTALL_DIR/toes"
|
echo " ${g}Installed to${r} ${b}$INSTALL_DIR/toes${r}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
|
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
|
||||||
echo "🐾 Add $INSTALL_DIR to your PATH to use toes globally"
|
echo " ${y}Add $INSTALL_DIR to your \$PATH, then:${r}"
|
||||||
fi
|
fi
|
||||||
|
echo " Run ${c}toes${r} to get started."
|
||||||
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export type App = {
|
||||||
tool?: boolean | string
|
tool?: boolean | string
|
||||||
apps?: boolean
|
apps?: boolean
|
||||||
dashboard?: boolean
|
dashboard?: boolean
|
||||||
|
share?: boolean
|
||||||
tunnelEnabled?: boolean
|
tunnelEnabled?: boolean
|
||||||
tunnelUrl?: string
|
tunnelUrl?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,36 +11,54 @@ interface Listener {
|
||||||
|
|
||||||
const _listeners = new Set<Listener>()
|
const _listeners = new Set<Listener>()
|
||||||
|
|
||||||
let _source: EventSource | undefined
|
let _abort: AbortController | undefined
|
||||||
|
|
||||||
function ensureConnection() {
|
function ensureConnection() {
|
||||||
if (_source) return
|
if (_abort) return
|
||||||
const url = `${process.env.TOES_URL}/api/events/stream`
|
const url = `${process.env.TOES_URL}/api/events/stream`
|
||||||
_source = new EventSource(url)
|
_abort = new AbortController()
|
||||||
|
connect(url, _abort.signal)
|
||||||
_source.onerror = () => {
|
|
||||||
if (_source?.readyState === EventSource.CLOSED) {
|
|
||||||
console.warn('[toes] Event stream closed unexpectedly')
|
|
||||||
_source = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_source.onmessage = (e) => {
|
|
||||||
try {
|
|
||||||
const event: ToesEvent = JSON.parse(e.data)
|
|
||||||
_listeners.forEach(l => {
|
|
||||||
if (l.types.includes(event.type)) l.callback(event)
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed events
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeConnection() {
|
function closeConnection() {
|
||||||
if (_source) {
|
if (_abort) {
|
||||||
_source.close()
|
_abort.abort()
|
||||||
_source = undefined
|
_abort = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(url: string, signal: AbortSignal) {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal })
|
||||||
|
if (!res.ok || !res.body) throw new Error(`SSE ${res.status}`)
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buf = ''
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
const parts = buf.split('\n\n')
|
||||||
|
buf = parts.pop()!
|
||||||
|
for (const part of parts) {
|
||||||
|
const line = part.split('\n').find(l => l.startsWith('data:'))
|
||||||
|
if (!line) continue
|
||||||
|
try {
|
||||||
|
const event: ToesEvent = JSON.parse(line.slice(5).trim())
|
||||||
|
_listeners.forEach(l => {
|
||||||
|
if (l.types.includes(event.type)) l.callback(event)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted) return
|
||||||
|
console.warn('[toes] Event stream error, reconnecting...')
|
||||||
|
}
|
||||||
|
if (!signal.aborted) await new Promise(r => setTimeout(r, 2000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user