Skip to content

Commit fca3189

Browse files
refactor(devtools): use stx serveApp() for full SPA pipeline
Replace custom rendering/fragment logic with stx's serveApp() which handles page processing, app shell, SPA fragment responses, TypeScript transpilation, and router injection natively. - index.ts reduced to WebSocket broadcasting + serveApp() call - Pages are fragments (no @extends) — serveApp handles shell composition - API routes defined in stx.config.ts for serveApp to discover - Delete src/layouts/app.stx — replaced by src/app.stx shell Note: SPA scope isolation during navigation still has issues upstream in stx's signals runtime (componentScope leaking between pages). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 22514ce commit fca3189

17 files changed

Lines changed: 65 additions & 277 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Auto-generated by STX - do not edit
2+
declare module "stx/routes" {
3+
interface RouteMap {
4+
'/': { }
5+
'/batch-details': { }
6+
'/batches': { }
7+
'/dependencies': { }
8+
'/group-details': { }
9+
'/groups': { }
10+
'/job-details': { }
11+
'/jobs': { }
12+
'/metrics': { }
13+
'/monitoring': { }
14+
'/queue-details': { }
15+
'/queues': { }
16+
}
17+
}

packages/devtools/src/app.stx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<template>
2+
<div class="bg-[#0a0a0f] text-zinc-50 leading-relaxed min-h-screen">
3+
@include('sidebar')
4+
5+
<div class="main-wrapper transition-all duration-200 ease-in-out min-h-screen" style="margin-left: 240px;">
6+
<main>
7+
<slot />
8+
</main>
9+
</div>
10+
</div>
11+
</template>
12+
13+
<script client>
14+
const collapsed = useLocalStorage('sidebar-collapsed', false)
15+
16+
function toggle() {
17+
collapsed.set(!collapsed())
18+
}
19+
</script>
20+
21+
<style>
22+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
23+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
24+
25+
.sidebar.collapsed { width: 56px !important; }
26+
.sidebar.collapsed .sidebar-brand-text { display: none; }
27+
.sidebar.collapsed .sidebar-toggle-btn { margin-left: auto; margin-right: auto; }
28+
.sidebar.collapsed .nav-label { opacity: 0; height: 0; padding: 0; overflow: hidden; }
29+
.sidebar.collapsed .nav-item-text { display: none; }
30+
.sidebar.collapsed ~ .main-wrapper { margin-left: 56px !important; }
31+
32+
#sidebar .nav-item.active { background: #6366f1 !important; color: white !important; }
33+
#sidebar .nav-item.active svg { opacity: 1; }
34+
</style>

packages/devtools/src/index.ts

Lines changed: 7 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,14 @@
11
import type { DashboardConfig } from './types'
22
import path from 'node:path'
3-
import { defaultConfig as stxDefaultConfig, isSpaNavigation, processDirectives, stripDocumentWrapper } from '@stacksjs/stx'
3+
import { serveApp } from '@stacksjs/stx'
44
import { BroadcastServer } from 'ts-broadcasting'
5-
import { createApiRoutes, fetchBatchById, fetchJobById, fetchJobGroups, fetchJobs, fetchQueueById } from './api'
65
import { resolveConfig } from './api'
76

87
export type { Batch, DashboardConfig, DashboardStats, DependencyGraph, DependencyNode, JobData, JobGroup, MetricsData, Queue, QueueMetrics } from './types'
98
export { JobStatus } from './types'
109
export { createApiRoutes, fetchBatches, fetchDashboardStats, fetchDependencyGraph, fetchJobGroups, fetchJobs, fetchMetrics, fetchQueueMetrics, fetchQueues } from './api'
1110

12-
const SRC_DIR = import.meta.dir
13-
const PAGES_DIR = path.join(SRC_DIR, 'pages')
14-
const FUNCTIONS_ENTRY = path.join(SRC_DIR, 'functions', 'browser.ts')
15-
1611
let broadcastServer: BroadcastServer | null = null
17-
let bundledFunctionsJs: string | null = null
18-
19-
const stxConfig = {
20-
...stxDefaultConfig,
21-
componentsDir: path.join(SRC_DIR, 'components'),
22-
layoutsDir: path.join(SRC_DIR, 'layouts'),
23-
partialsDir: path.join(SRC_DIR, 'partials'),
24-
}
25-
26-
async function buildFunctionsBundle(): Promise<string> {
27-
if (bundledFunctionsJs) return bundledFunctionsJs
28-
const result = await Bun.build({ entrypoints: [FUNCTIONS_ENTRY], target: 'browser', minify: false, format: 'iife' })
29-
if (!result.success) { console.error('Failed to build functions bundle:', result.logs); return '' } // eslint-disable-line no-console
30-
bundledFunctionsJs = await result.outputs[0].text()
31-
return bundledFunctionsJs
32-
}
33-
34-
async function renderPage(templateName: string, wsUrl: string, req: Request): Promise<Response> {
35-
const templatePath = path.join(PAGES_DIR, `${templateName}.stx`)
36-
const content = await Bun.file(templatePath).text()
37-
const context: Record<string, any> = { __filename: templatePath, __dirname: path.dirname(templatePath) }
38-
39-
// SPA navigation — process page as fragment (strip layout directives)
40-
if (isSpaNavigation(req)) {
41-
const pageContent = content
42-
.replace(/@extends\s*\(\s*['"][^'"]+['"]\s*\)/g, '')
43-
.replace(/@section\s*\(\s*'title'\s*\)[^@]*@endsection/g, '')
44-
.replace(/@section\s*\(\s*'content'\s*\)/g, '')
45-
.replace(/@endsection\s*$/gm, '')
46-
.replace(/@push\s*\(\s*['"][^'"]+['"]\s*\)/g, '')
47-
.replace(/@endpush/g, '')
48-
49-
let fragment = await processDirectives(pageContent, context, templatePath, stxConfig, new Set())
50-
fragment = stripDocumentWrapper(fragment)
51-
// Strip signals runtime — shell already has it
52-
fragment = fragment.replace(/<script data-stx-scoped>\(function\(\)\{[^]*?<\/script>/, '')
53-
54-
return new Response(fragment, {
55-
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store', 'X-STX-Fragment': 'true' },
56-
})
57-
}
58-
59-
// Full page — processDirectives handles @extends, layout, signals runtime, everything
60-
let html = await processDirectives(content, context, templatePath, stxConfig, new Set())
61-
html = html.replace('</head>', `<script>window.__BQ_WS_URL = "${wsUrl}";</script>\n</head>`)
62-
return new Response(html, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' } })
63-
}
6412

6513
function wireQueueEvents(queues: any[]): void {
6614
if (!broadcastServer) return
@@ -78,96 +26,22 @@ function wireQueueEvents(queues: any[]): void {
7826
}
7927
}
8028

81-
function handleDynamicApiRoute(pathname: string, req: Request, config: DashboardConfig): Promise<Response> | null {
82-
const queueMatch = pathname.match(/^\/api\/queues\/([^/]+)$/)
83-
if (queueMatch) return fetchQueueById(config, queueMatch[1]).then(q => q ? Response.json(q) : Response.json({ error: 'Queue not found' }, { status: 404 }))
84-
85-
const retryMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/retry$/)
86-
if (retryMatch && req.method === 'POST') {
87-
const jobId = decodeURIComponent(retryMatch[1])
88-
const qs = config.queues?.length ? config.queues : config.queueManager ? (() => { const arr: any[] = []; for (const c of config.queueManager!.getConnections()) { try { const conn = config.queueManager!.connection(c); for (const q of conn.queues.values()) arr.push(q) } catch {} } return arr })() : []
89-
return (async () => { for (const q of qs) { try { const r = await q.retryJob(jobId); if (r) return Response.json({ success: true }) } catch {} } return Response.json({ success: false }) })()
90-
}
91-
92-
const deleteMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/)
93-
if (deleteMatch && req.method === 'DELETE') {
94-
const jobId = decodeURIComponent(deleteMatch[1])
95-
const qs = config.queues?.length ? config.queues : config.queueManager ? (() => { const arr: any[] = []; for (const c of config.queueManager!.getConnections()) { try { const conn = config.queueManager!.connection(c); for (const q of conn.queues.values()) arr.push(q) } catch {} } return arr })() : []
96-
return (async () => { for (const q of qs) { try { await q.removeJob(jobId); return Response.json({ success: true }) } catch {} } return Response.json({ success: false }) })()
97-
}
98-
99-
const jobMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/)
100-
if (jobMatch && req.method === 'GET') return fetchJobById(config, jobMatch[1]).then(j => j ? Response.json(j) : Response.json({ error: 'Job not found' }, { status: 404 }))
101-
102-
const groupMatch = pathname.match(/^\/api\/groups\/([^/]+)$/)
103-
if (groupMatch) return fetchJobGroups(config).then(groups => { const g = groups.find(x => x.id === groupMatch[1]); return g ? Response.json(g) : Response.json({ error: 'Group not found' }, { status: 404 }) })
104-
105-
const groupJobsMatch = pathname.match(/^\/api\/groups\/([^/]+)\/jobs$/)
106-
if (groupJobsMatch) return fetchJobGroups(config).then(async (groups) => { const g = groups.find(x => x.id === groupJobsMatch[1]); if (!g) return Response.json({ error: 'Group not found' }, { status: 404 }); const allJobs = await fetchJobs(config); return Response.json(allJobs.filter(j => j.name.toLowerCase().includes(g.name.toLowerCase().split(' ')[0]))) })
107-
108-
const batchMatch = pathname.match(/^\/api\/batches\/([^/]+)$/)
109-
if (batchMatch) return fetchBatchById(config, batchMatch[1]).then(b => b ? Response.json(b) : Response.json({ error: 'Batch not found' }, { status: 404 }))
110-
111-
const batchJobsMatch = pathname.match(/^\/api\/batches\/([^/]+)\/jobs$/)
112-
if (batchJobsMatch) return fetchBatchById(config, batchJobsMatch[1]).then(async (b) => { if (!b) return Response.json({ error: 'Batch not found' }, { status: 404 }); const allJobs = await fetchJobs(config); return Response.json(allJobs.slice(0, b.totalJobs > 10 ? 10 : b.totalJobs)) })
113-
114-
return null
115-
}
116-
11729
export async function serveDashboard(options: DashboardConfig = {}): Promise<void> {
11830
const config = resolveConfig(options)
119-
const apiRoutes = createApiRoutes(config)
12031

32+
// Start WebSocket broadcast server
12133
const broadcastPort = options.broadcastPort || 6001
12234
broadcastServer = new BroadcastServer({ connections: { default: { driver: 'bun', host: '0.0.0.0', port: broadcastPort } }, default: 'default', debug: false })
12335
await broadcastServer.start()
12436

12537
const allQueues = config.queues || []
12638
if (allQueues.length) wireQueueEvents(allQueues)
12739

128-
const wsUrl = `ws://localhost:${broadcastPort}/app`
129-
130-
const pageMap: Record<string, string> = {
131-
'/': 'index', '/monitoring': 'monitoring', '/metrics': 'metrics', '/queues': 'queues',
132-
'/jobs': 'jobs', '/batches': 'batches', '/groups': 'groups', '/dependencies': 'dependencies',
133-
}
134-
const dynamicPages = [
135-
{ pattern: /^\/queues\/[^/]+$/, template: 'queue-details' },
136-
{ pattern: /^\/jobs\/[^/]+$/, template: 'job-details' },
137-
{ pattern: /^\/batches\/[^/]+$/, template: 'batch-details' },
138-
{ pattern: /^\/groups\/[^/]+$/, template: 'group-details' },
139-
]
140-
141-
Bun.serve({
40+
// Let stx handle everything — pages, layout, SPA fragments, routing
41+
const appDir = path.join(import.meta.dir)
42+
await serveApp(appDir, {
14243
port: config.port,
143-
hostname: config.host,
144-
async fetch(req: Request) {
145-
const pathname = new URL(req.url).pathname
146-
147-
// Static API routes
148-
const apiHandler = apiRoutes[pathname as keyof typeof apiRoutes]
149-
if (apiHandler) return apiHandler(req)
150-
151-
// Dynamic API routes
152-
const dynamicApi = handleDynamicApiRoute(pathname, req, config)
153-
if (dynamicApi) return dynamicApi
154-
155-
// Shared functions bundle
156-
if (pathname === '/bq-utils.js') {
157-
const js = await buildFunctionsBundle()
158-
return new Response(js, { headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' } })
159-
}
160-
161-
// Page routes
162-
if (pageMap[pathname]) return renderPage(pageMap[pathname], wsUrl, req)
163-
for (const { pattern, template } of dynamicPages) {
164-
if (pattern.test(pathname)) return renderPage(template, wsUrl, req)
165-
}
166-
167-
return new Response('Not Found', { status: 404 })
168-
},
44+
watch: true,
45+
hotReload: false,
16946
})
170-
171-
console.log(`bun-queue dashboard running at http://localhost:${config.port}`) // eslint-disable-line no-console
172-
console.log(`WebSocket broadcast server running at ws://localhost:${broadcastPort}/app`) // eslint-disable-line no-console
17347
}

packages/devtools/src/layouts/app.stx

Lines changed: 0 additions & 46 deletions
This file was deleted.

packages/devtools/src/pages/batch-details.stx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
@extends('../layouts/app')
2-
3-
@section('title')Batch Details — bun-queue@endsection
4-
5-
@push('styles')
61
<style>
72
.progress-ring-container svg { transform: rotate(-90deg); }
83
.progress-ring-bg { fill: none; stroke: #1e1e26; stroke-width: 10; }
94
.progress-ring-fill { fill: none; stroke: #10b981; stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset 0.6s ease; }
105
</style>
11-
@endpush
126

13-
@section('content')
147
<script client>
158
interface BatchData {
169
id: string
@@ -221,4 +214,3 @@
221214
</table>
222215
</div>
223216
</div>
224-
@endsection

packages/devtools/src/pages/batches.stx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
1-
@extends('../layouts/app')
2-
3-
@section('title')Batches — bun-queue@endsection
4-
5-
@push('styles')
61
<style>
72
.progress-bar-fill { transition: width 0.5s ease; }
83
</style>
9-
@endpush
104

11-
@section('content')
125
<script client>
136
interface Batch {
147
id: string
@@ -171,4 +164,3 @@
171164
</div>
172165
</div>
173166
</div>
174-
@endsection

packages/devtools/src/pages/dependencies.stx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
1-
@extends('../layouts/app')
2-
3-
@section('title')Job Dependencies — bun-queue@endsection
4-
5-
@push('head')
61
<script src='https://d3js.org/d3.v7.min.js'></script>
7-
@endpush
82

9-
@push('styles')
103
<style>
114
.graph-container svg { width: 100%; height: 100%; }
125
.link { stroke: #27272a; stroke-width: 1.5; fill: none; }
@@ -15,9 +8,7 @@
158
.node-circle:active { cursor: grabbing; }
169
.node-label { font-size: 10px; fill: #a1a1aa; text-anchor: middle; pointer-events: none; user-select: none; }
1710
</style>
18-
@endpush
1911

20-
@section('content')
2112
<script client>
2213
interface GraphNode {
2314
id: string
@@ -241,4 +232,3 @@
241232
<div class='mt-1' ref='tooltip-status'></div>
242233
</div>
243234
</div>
244-
@endsection

packages/devtools/src/pages/group-details.stx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
@extends('../layouts/app')
2-
3-
@section('title')Group Details — bun-queue@endsection
4-
5-
@section('content')
61
<script client>
72
interface GroupData {
83
name?: string
@@ -167,4 +162,3 @@
167162
</table>
168163
</div>
169164
</div>
170-
@endsection

packages/devtools/src/pages/groups.stx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
@extends('../layouts/app')
2-
3-
@section('title')Job Groups — bun-queue@endsection
4-
5-
@section('content')
61
<script client>
72
interface Group {
83
id: string
@@ -128,4 +123,3 @@
128123
</div>
129124
</div>
130125
</div>
131-
@endsection

packages/devtools/src/pages/index.stx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,10 @@
1-
@extends('../layouts/app')
2-
3-
@section('title')bun-queue Dashboard@endsection
4-
5-
@push('head')
61
<script src='https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js'></script>
7-
@endpush
82

9-
@push('styles')
103
<style>
114
.chart-container { position: relative; height: 220px; width: 100%; }
125
.chart-container-sm { position: relative; height: 180px; width: 100%; }
136
</style>
14-
@endpush
157

16-
@section('content')
178
<script client>
189
interface DashboardStats {
1910
totalQueues: number
@@ -330,4 +321,3 @@
330321
</div>
331322
</div>
332323
</div>
333-
@endsection

0 commit comments

Comments
 (0)