11import type { DashboardConfig } from './types'
22import 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'
44import { BroadcastServer } from 'ts-broadcasting'
55import { createApiRoutes , fetchBatchById , fetchBatches , fetchDashboardStats , fetchDependencyGraph , fetchJobById , fetchJobGroups , fetchJobs , fetchMetrics , fetchQueueById , fetchQueues } from './api'
66import { resolveConfig } from './api'
@@ -9,11 +9,40 @@ export type { Batch, DashboardConfig, DashboardStats, DependencyGraph, Dependenc
99export { JobStatus } from './types'
1010export { 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
1517let broadcastServer : BroadcastServer | null = null
1618let 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 ( / < s c r i p t > [ \s \S ] * ?_ _ s t x R o u t e r [ \s \S ] * ?< \/ s c r i p t > / )
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
1847async 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 ( / < t e m p l a t e \b [ ^ > ] * > ( [ \s \S ] * ?) < \/ t e m p l a t e > / i)
79+ let shellTemplate = templateMatch ? templateMatch [ 1 ] . trim ( ) : shellContent
80+
81+ // Extract <style> blocks from the full file
82+ const styles = ( shellContent . match ( / < s t y l e \b [ ^ > ] * > [ \s \S ] * ?< \/ s t y l e > / gi) || [ ] ) . join ( '\n' )
83+
84+ // Extract <script client> blocks, process them through stx for TypeScript transpilation
85+ const clientScriptMatches = shellContent . match ( / < s c r i p t \b [ ^ > ] * \b c l i e n t \b [ ^ > ] * > [ \s \S ] * ?< \/ s c r i p t > / 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 ( / < s c r i p t \b [ ^ > ] * > [ \s \S ] * ? < \/ s c r i p t > / gi , '' )
97+ shellTemplate = shellTemplate . replace ( / < s t y l e \b [ ^ > ] * > [ \s \S ] * ? < \/ s t y l e > / 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 ( / < s l o t \s * \/ > / gi, SLOT ) . replace ( / < s l o t \s * > \s * < \/ s l o t > / 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
58176function 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 ( / ^ \/ a p i \/ q u e u e s \/ ( [ ^ / ] + ) $ / )
271+ const queueMatch = pathname . match ( / ^ \/ a p i \/ q u e u e s \/ ( [ ^ / ] + ) $ / )
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 ( / ^ \/ a p i \/ j o b s \/ ( [ ^ / ] + ) \/ r e t r y $ / )
280+ const retryMatch = pathname . match ( / ^ \/ a p i \/ j o b s \/ ( [ ^ / ] + ) \/ r e t r y $ / )
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 ( / ^ \/ a p i \/ j o b s \/ ( [ ^ / ] + ) $ / )
311+ const deleteMatch = pathname . match ( / ^ \/ a p i \/ j o b s \/ ( [ ^ / ] + ) $ / )
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 ( / ^ \/ a p i \/ j o b s \/ ( [ ^ / ] + ) $ / )
342+ const jobMatch = pathname . match ( / ^ \/ a p i \/ j o b s \/ ( [ ^ / ] + ) $ / )
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 ( / ^ \/ a p i \/ g r o u p s \/ ( [ ^ / ] + ) $ / )
350+ const groupMatch = pathname . match ( / ^ \/ a p i \/ g r o u p s \/ ( [ ^ / ] + ) $ / )
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 ( / ^ \/ a p i \/ g r o u p s \/ ( [ ^ / ] + ) \/ j o b s $ / )
358+ const groupJobsMatch = pathname . match ( / ^ \/ a p i \/ g r o u p s \/ ( [ ^ / ] + ) \/ j o b s $ / )
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 ( / ^ \/ a p i \/ b a t c h e s \/ ( [ ^ / ] + ) $ / )
369+ const batchMatch = pathname . match ( / ^ \/ a p i \/ b a t c h e s \/ ( [ ^ / ] + ) $ / )
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 ( / ^ \/ a p i \/ b a t c h e s \/ ( [ ^ / ] + ) \/ j o b s $ / )
377+ const batchJobsMatch = pathname . match ( / ^ \/ a p i \/ b a t c h e s \/ ( [ ^ / ] + ) \/ j o b s $ / )
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
0 commit comments