Skip to content

Commit 3ae9d71

Browse files
refactor(devtools): migrate to stx v2 app shell for SPA navigation
- Create src/app.stx as the app shell with <slot /> for page injection - Convert all 12 pages from @extends/@section/@Push to plain fragments - Server composes shell + page for full requests, returns fragments for SPA navigation (X-STX-Router / X-STX-Fragment headers) - Signals runtime placed in <head> so it loads before any page scripts - Router script injected explicitly (injectRouterScript skips when signals runtime references __stxRouter) - Delete src/layouts/app.stx (replaced by app shell) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ee5f3db commit 3ae9d71

File tree

16 files changed

+2431
-2473
lines changed

16 files changed

+2431
-2473
lines changed

packages/devtools/src/app.stx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<template>
2+
<div class="bg-[#0a0a0f] text-zinc-50 leading-relaxed min-h-screen">
3+
<script src="/bq-utils.js"></script>
4+
5+
@include('sidebar')
6+
7+
<div class="main-wrapper transition-all duration-200 ease-in-out min-h-screen" ref="main-wrapper" style="margin-left: 240px;">
8+
<slot />
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 state overrides */
26+
.sidebar.collapsed { width: 56px !important; }
27+
.sidebar.collapsed .sidebar-brand-text { display: none; }
28+
.sidebar.collapsed .sidebar-toggle-btn { margin-left: auto; margin-right: auto; }
29+
.sidebar.collapsed .nav-label { opacity: 0; height: 0; padding: 0; overflow: hidden; }
30+
.sidebar.collapsed .nav-item-text { display: none; }
31+
.sidebar.collapsed ~ .main-wrapper { margin-left: 56px !important; }
32+
33+
/* Active state — managed by StxLink + router via data-stx-active-class="active" */
34+
#sidebar .nav-item.active { background: #6366f1 !important; color: white !important; }
35+
#sidebar .nav-item.active svg { opacity: 1; }
36+
</style>

packages/devtools/src/index.ts

Lines changed: 153 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DashboardConfig } from './types'
22
import path from 'node:path'
3-
import { defaultConfig as stxDefaultConfig, injectRouterScript, processDirectives } from '@stacksjs/stx'
3+
import { defaultConfig as stxDefaultConfig, generateSignalsRuntime, injectRouterScript, isSpaNavigation, processDirectives, stripDocumentWrapper } from '@stacksjs/stx'
44
import { BroadcastServer } from 'ts-broadcasting'
55
import { createApiRoutes, fetchBatchById, fetchBatches, fetchDashboardStats, fetchDependencyGraph, fetchJobById, fetchJobGroups, fetchJobs, fetchMetrics, fetchQueueById, fetchQueues } from './api'
66
import { resolveConfig } from './api'
@@ -9,11 +9,40 @@ export type { Batch, DashboardConfig, DashboardStats, DependencyGraph, Dependenc
99
export { JobStatus } from './types'
1010
export { createApiRoutes, fetchBatches, fetchDashboardStats, fetchDependencyGraph, fetchJobGroups, fetchJobs, fetchMetrics, fetchQueueMetrics, fetchQueues } from './api'
1111

12-
const PAGES_DIR = path.join(import.meta.dir, 'pages')
13-
const FUNCTIONS_ENTRY = path.join(import.meta.dir, 'functions', 'browser.ts')
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+
const SHELL_PATH = path.join(SRC_DIR, 'app.stx')
1416

1517
let broadcastServer: BroadcastServer | null = null
1618
let bundledFunctionsJs: string | null = null
19+
let cachedShell: { before: string, after: string, styles: string, scripts: string } | null = null
20+
let cachedRouterScript: string | null = null
21+
22+
function getRouterScriptTag(): string {
23+
if (cachedRouterScript) return cachedRouterScript
24+
// Extract the router script by injecting into a minimal HTML doc
25+
const minimal = '<!DOCTYPE html><html><head></head><body></body></html>'
26+
const injected = injectRouterScript(minimal)
27+
const match = injected.match(/<script>[\s\S]*?__stxRouter[\s\S]*?<\/script>/)
28+
cachedRouterScript = match ? match[0] : ''
29+
return cachedRouterScript
30+
}
31+
32+
const pageTitles: Record<string, string> = {
33+
'index': 'bun-queue Dashboard',
34+
'monitoring': 'Real-time Monitoring — bun-queue',
35+
'metrics': 'Performance Metrics — bun-queue',
36+
'queues': 'Queues — bun-queue',
37+
'queue-details': 'Queue Details — bun-queue',
38+
'jobs': 'Jobs — bun-queue',
39+
'job-details': 'Job Details — bun-queue',
40+
'batches': 'Batches — bun-queue',
41+
'batch-details': 'Batch Details — bun-queue',
42+
'groups': 'Job Groups — bun-queue',
43+
'group-details': 'Group Details — bun-queue',
44+
'dependencies': 'Job Dependencies — bun-queue',
45+
}
1746

1847
async function buildFunctionsBundle(): Promise<string> {
1948
if (bundledFunctionsJs) return bundledFunctionsJs
@@ -34,25 +63,114 @@ async function buildFunctionsBundle(): Promise<string> {
3463
return bundledFunctionsJs
3564
}
3665

37-
async function renderStxPage(templateName: string, wsUrl: string): Promise<string> {
38-
const templatePath = path.join(PAGES_DIR, `${templateName}.stx`)
39-
const content = await Bun.file(templatePath).text()
66+
const stxConfig = {
67+
...stxDefaultConfig,
68+
componentsDir: path.join(SRC_DIR, 'components'),
69+
partialsDir: path.join(SRC_DIR, 'partials'),
70+
}
71+
72+
async function getShellParts(): Promise<{ before: string, after: string, styles: string, scripts: string, signalsRuntime: string }> {
73+
if (cachedShell) return cachedShell
4074

41-
const config = {
42-
...stxDefaultConfig,
43-
componentsDir: path.join(import.meta.dir, 'components'),
44-
layoutsDir: path.join(import.meta.dir, 'layouts'),
45-
partialsDir: path.join(import.meta.dir, 'partials'),
75+
const shellContent = await Bun.file(SHELL_PATH).text()
76+
77+
// Extract <template> block
78+
const templateMatch = shellContent.match(/<template\b[^>]*>([\s\S]*?)<\/template>/i)
79+
let shellTemplate = templateMatch ? templateMatch[1].trim() : shellContent
80+
81+
// Extract <style> blocks from the full file
82+
const styles = (shellContent.match(/<style\b[^>]*>[\s\S]*?<\/style>/gi) || []).join('\n')
83+
84+
// Extract <script client> blocks, process them through stx for TypeScript transpilation
85+
const clientScriptMatches = shellContent.match(/<script\b[^>]*\bclient\b[^>]*>[\s\S]*?<\/script>/gi) || []
86+
let scripts = ''
87+
if (clientScriptMatches.length > 0) {
88+
const scriptHtml = clientScriptMatches.join('\n')
89+
const scriptContext = { __filename: SHELL_PATH, __dirname: path.dirname(SHELL_PATH) }
90+
// Skip runtime here too — we extract it separately
91+
scripts = await processDirectives(scriptHtml, scriptContext, SHELL_PATH, { ...stxConfig, skipSignalsRuntime: true }, new Set())
92+
scripts = stripDocumentWrapper(scripts)
4693
}
4794

48-
const context = { __filename: templatePath, __dirname: path.dirname(templatePath) }
49-
let html = await processDirectives(content, context, templatePath, config, new Set())
50-
html = injectRouterScript(html)
95+
// Remove scripts and styles from template for processing
96+
shellTemplate = shellTemplate.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
97+
shellTemplate = shellTemplate.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
5198

52-
// Inject WebSocket URL for real-time updates
53-
html = html.replace('</head>', `<script>window.__BQ_WS_URL = "${wsUrl}";</script>\n</head>`)
99+
// Replace <slot /> with placeholder
100+
const SLOT = '<!--__STX_SLOT__-->'
101+
shellTemplate = shellTemplate.replace(/<slot\s*\/>/gi, SLOT).replace(/<slot\s*>\s*<\/slot>/gi, SLOT)
54102

55-
return html
103+
// Process shell template WITHOUT signals runtime — we'll place it in <head> ourselves
104+
const context = { __filename: SHELL_PATH, __dirname: path.dirname(SHELL_PATH) }
105+
let processed = await processDirectives(shellTemplate, context, SHELL_PATH, { ...stxConfig, skipSignalsRuntime: true }, new Set())
106+
processed = stripDocumentWrapper(processed)
107+
108+
// Get the signals runtime directly
109+
const signalsRuntime = `<script data-stx-scoped>${generateSignalsRuntime()}</script>`
110+
111+
const slotIdx = processed.indexOf(SLOT)
112+
if (slotIdx === -1) {
113+
console.warn('[bq-devtools] Shell has no <slot /> — falling back')
114+
cachedShell = { before: '', after: '', styles, scripts, signalsRuntime }
115+
return cachedShell
116+
}
117+
118+
cachedShell = {
119+
before: processed.substring(0, slotIdx),
120+
after: processed.substring(slotIdx + SLOT.length),
121+
styles,
122+
scripts,
123+
signalsRuntime,
124+
}
125+
return cachedShell
126+
}
127+
128+
async function renderStxPage(templateName: string, wsUrl: string, req: Request): Promise<Response> {
129+
const templatePath = path.join(PAGES_DIR, `${templateName}.stx`)
130+
const content = await Bun.file(templatePath).text()
131+
132+
const context = { __filename: templatePath, __dirname: path.dirname(templatePath) }
133+
// Skip signals runtime injection for page fragments — the shell provides it
134+
const pageConfig = { ...stxConfig, skipSignalsRuntime: true }
135+
let pageHtml = await processDirectives(content, context, templatePath, pageConfig, new Set())
136+
pageHtml = stripDocumentWrapper(pageHtml)
137+
138+
// SPA navigation — return fragment only
139+
if (isSpaNavigation(req)) {
140+
return new Response(pageHtml, {
141+
headers: {
142+
'Content-Type': 'text/html',
143+
'Cache-Control': 'no-store',
144+
'X-STX-Fragment': 'true',
145+
},
146+
})
147+
}
148+
149+
// Full page request — compose with shell
150+
const shell = await getShellParts()
151+
const title = pageTitles[templateName] || 'bun-queue'
152+
153+
let html = `<!DOCTYPE html>
154+
<html lang="en">
155+
<head>
156+
<meta charset="UTF-8">
157+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
158+
<title>${title}</title>
159+
<script src="/bq-utils.js"><\/script>
160+
<script>window.__BQ_WS_URL = "${wsUrl}";window.__stxRouterConfig={container:'[data-stx-content]'};<\/script>
161+
${shell.styles}
162+
${shell.signalsRuntime}
163+
</head>
164+
<body class="bg-[#0a0a0f] text-zinc-50 leading-relaxed min-h-screen">
165+
${shell.before}
166+
<div data-stx-content>${pageHtml}</div>
167+
${shell.after}
168+
${shell.scripts}
169+
${getRouterScriptTag()}
170+
</body>
171+
</html>`
172+
173+
return new Response(html, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' } })
56174
}
57175

58176
function wireQueueEvents(queues: any[]): void {
@@ -109,6 +227,9 @@ export async function serveDashboard(options: DashboardConfig = {}): Promise<voi
109227
const config = resolveConfig(options)
110228
const apiRoutes = createApiRoutes(config)
111229

230+
// Pre-process the app shell at startup
231+
await getShellParts()
232+
112233
// Start WebSocket broadcast server for real-time updates
113234
const broadcastPort = options.broadcastPort || 6001
114235
broadcastServer = new BroadcastServer({
@@ -138,16 +259,16 @@ export async function serveDashboard(options: DashboardConfig = {}): Promise<voi
138259

139260
async fetch(req: Request) {
140261
const url = new URL(req.url)
141-
const path = url.pathname
262+
const pathname = url.pathname
142263

143264
// API routes
144-
const apiHandler = apiRoutes[path as keyof typeof apiRoutes]
265+
const apiHandler = apiRoutes[pathname as keyof typeof apiRoutes]
145266
if (apiHandler) {
146267
return apiHandler(req)
147268
}
148269

149270
// Dynamic API routes (with path params)
150-
const queueMatch = path.match(/^\/api\/queues\/([^/]+)$/)
271+
const queueMatch = pathname.match(/^\/api\/queues\/([^/]+)$/)
151272
if (queueMatch) {
152273
const queue = await fetchQueueById(config, queueMatch[1])
153274
if (!queue)
@@ -156,14 +277,13 @@ export async function serveDashboard(options: DashboardConfig = {}): Promise<voi
156277
}
157278

158279
// Job retry: POST /api/jobs/:id/retry
159-
const retryMatch = path.match(/^\/api\/jobs\/([^/]+)\/retry$/)
280+
const retryMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/retry$/)
160281
if (retryMatch && req.method === 'POST') {
161282
const jobId = decodeURIComponent(retryMatch[1])
162283
const queues = config.queues || []
163284
const manager = config.queueManager
164285
let retried = false
165286

166-
// Try to find and retry the job across all queues
167287
const allQueues = queues.length ? queues : manager ? (() => {
168288
const qs: any[] = []
169289
for (const connName of manager.getConnections()) {
@@ -188,7 +308,7 @@ catch { /* try next queue */ }
188308
}
189309

190310
// Job delete: DELETE /api/jobs/:id
191-
const deleteMatch = path.match(/^\/api\/jobs\/([^/]+)$/)
311+
const deleteMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/)
192312
if (deleteMatch && req.method === 'DELETE') {
193313
const jobId = decodeURIComponent(deleteMatch[1])
194314
const queues = config.queues || []
@@ -219,23 +339,23 @@ catch { /* try next queue */ }
219339
return Response.json({ success: deleted })
220340
}
221341

222-
const jobMatch = path.match(/^\/api\/jobs\/([^/]+)$/)
342+
const jobMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/)
223343
if (jobMatch) {
224344
const job = await fetchJobById(config, jobMatch[1])
225345
if (!job)
226346
return Response.json({ error: 'Job not found' }, { status: 404 })
227347
return Response.json(job)
228348
}
229349

230-
const groupMatch = path.match(/^\/api\/groups\/([^/]+)$/)
350+
const groupMatch = pathname.match(/^\/api\/groups\/([^/]+)$/)
231351
if (groupMatch) {
232352
const group = await fetchJobGroups(config).then(groups => groups.find(g => g.id === groupMatch[1]))
233353
if (!group)
234354
return Response.json({ error: 'Group not found' }, { status: 404 })
235355
return Response.json(group)
236356
}
237357

238-
const groupJobsMatch = path.match(/^\/api\/groups\/([^/]+)\/jobs$/)
358+
const groupJobsMatch = pathname.match(/^\/api\/groups\/([^/]+)\/jobs$/)
239359
if (groupJobsMatch) {
240360
const group = await fetchJobGroups(config).then(groups => groups.find(g => g.id === groupJobsMatch[1]))
241361
if (!group)
@@ -246,15 +366,15 @@ catch { /* try next queue */ }
246366
return Response.json(groupJobs)
247367
}
248368

249-
const batchMatch = path.match(/^\/api\/batches\/([^/]+)$/)
369+
const batchMatch = pathname.match(/^\/api\/batches\/([^/]+)$/)
250370
if (batchMatch) {
251371
const batch = await fetchBatchById(config, batchMatch[1])
252372
if (!batch)
253373
return Response.json({ error: 'Batch not found' }, { status: 404 })
254374
return Response.json(batch)
255375
}
256376

257-
const batchJobsMatch = path.match(/^\/api\/batches\/([^/]+)\/jobs$/)
377+
const batchJobsMatch = pathname.match(/^\/api\/batches\/([^/]+)\/jobs$/)
258378
if (batchJobsMatch) {
259379
const batch = await fetchBatchById(config, batchJobsMatch[1])
260380
if (!batch)
@@ -264,7 +384,7 @@ catch { /* try next queue */ }
264384
}
265385

266386
// Shared functions bundle
267-
if (path === '/bq-utils.js') {
387+
if (pathname === '/bq-utils.js') {
268388
const js = await buildFunctionsBundle()
269389
return new Response(js, { headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' } })
270390
}
@@ -281,9 +401,8 @@ catch { /* try next queue */ }
281401
'/dependencies': 'dependencies',
282402
}
283403

284-
if (pageMap[path]) {
285-
const html = await renderStxPage(pageMap[path], wsUrl)
286-
return new Response(html, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' } })
404+
if (pageMap[pathname]) {
405+
return renderStxPage(pageMap[pathname], wsUrl, req)
287406
}
288407

289408
// Dynamic page routes (detail views)
@@ -295,9 +414,8 @@ catch { /* try next queue */ }
295414
]
296415

297416
for (const { pattern, template } of dynamicPages) {
298-
if (pattern.test(path)) {
299-
const html = await renderStxPage(template, wsUrl)
300-
return new Response(html, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' } })
417+
if (pattern.test(pathname)) {
418+
return renderStxPage(template, wsUrl, req)
301419
}
302420
}
303421

packages/devtools/src/layouts/app.stx

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

0 commit comments

Comments
 (0)