Frontend Process Watchdog¶
A backend + frontend example that spawns a supervised frontend sync loop, heartbeats it, and reports lifecycle changes back into a drawer tab UI.
spindle.json¶
{
"version": "1.0.0",
"name": "Frontend Watchdog Demo",
"identifier": "frontend_watchdog_demo",
"author": "Dev",
"github": "https://github.com/dev/frontend-watchdog-demo",
"homepage": "https://github.com/dev/frontend-watchdog-demo",
"permissions": [],
"entry_backend": "dist/backend.js",
"entry_frontend": "dist/frontend.js"
}
src/backend.ts¶
declare const spindle: import('lumiverse-spindle-types').SpindleAPI
const activeByUser = new Map<string, string>()
spindle.onFrontendMessage(async (payload: any, userId) => {
switch (payload?.type) {
case 'start_loop': {
const intervalMs = Math.max(1000, Math.min(30_000, Number(payload.intervalMs) || 5000))
const process = await spindle.frontendProcesses.spawn({
kind: 'watchdog-loop',
key: 'main',
userId,
payload: { intervalMs },
startupTimeoutMs: 10_000,
heartbeatTimeoutMs: intervalMs * 2 + 2000,
replaceExisting: true,
})
activeByUser.set(userId, process.processId)
spindle.sendToFrontend({
type: 'watchdog_status',
processId: process.processId,
state: 'starting',
intervalMs,
}, userId)
break
}
case 'stop_loop': {
const processId = activeByUser.get(userId)
if (!processId) return
await spindle.frontendProcesses.stop(processId, {
userId,
reason: 'user_requested',
})
break
}
}
})
spindle.frontendProcesses.onMessage((event) => {
if ((event.payload as any)?.type !== 'tick') return
spindle.sendToFrontend({
type: 'watchdog_tick',
processId: event.processId,
at: (event.payload as any).at,
count: (event.payload as any).count,
}, event.userId)
})
spindle.frontendProcesses.onLifecycle((event) => {
if (event.userId) {
if (activeByUser.get(event.userId) === event.processId) {
if (['stopped', 'completed', 'failed', 'timed_out'].includes(event.state)) {
activeByUser.delete(event.userId)
}
}
spindle.sendToFrontend({
type: 'watchdog_status',
processId: event.processId,
state: event.state,
error: event.error,
exitReason: event.exitReason,
}, event.userId)
}
})
spindle.log.info('Frontend watchdog demo loaded')
src/frontend.ts¶
import type { SpindleFrontendContext } from 'lumiverse-spindle-types'
export function setup(ctx: SpindleFrontendContext) {
const tab = ctx.ui.registerDrawerTab({
id: 'watchdog-demo',
title: 'Watchdog Demo',
shortName: 'Watchdog',
description: 'Start and stop a supervised frontend loop',
keywords: ['watchdog', 'heartbeat', 'process'],
})
tab.root.innerHTML = `
<div style="padding:16px; display:grid; gap:12px;">
<label style="display:grid; gap:6px;">
<span>Heartbeat interval (ms)</span>
<input data-watchdog-interval type="number" min="1000" step="500" value="5000" />
</label>
<div style="display:flex; gap:8px;">
<button data-watchdog-start>Start</button>
<button data-watchdog-stop>Stop</button>
</div>
<div data-watchdog-status>Status: idle</div>
<div data-watchdog-ticks>Ticks: 0</div>
</div>
`
const intervalInput = tab.root.querySelector('[data-watchdog-interval]') as HTMLInputElement | null
const startBtn = tab.root.querySelector('[data-watchdog-start]') as HTMLButtonElement | null
const stopBtn = tab.root.querySelector('[data-watchdog-stop]') as HTMLButtonElement | null
const statusEl = tab.root.querySelector('[data-watchdog-status]') as HTMLDivElement | null
const ticksEl = tab.root.querySelector('[data-watchdog-ticks]') as HTMLDivElement | null
let tickCount = 0
const unregisterProcess = ctx.processes.register('watchdog-loop', (process) => {
let counter = 0
const intervalMs = Math.max(1000, Number((process.payload as any)?.intervalMs) || 5000)
process.ready()
const timer = setInterval(() => {
counter += 1
process.heartbeat()
process.send({
type: 'tick',
count: counter,
at: Date.now(),
})
}, intervalMs)
const unsubStop = process.onStop(() => {
clearInterval(timer)
process.complete()
})
return () => {
unsubStop()
clearInterval(timer)
}
})
startBtn?.addEventListener('click', () => {
tickCount = 0
if (ticksEl) ticksEl.textContent = 'Ticks: 0'
ctx.sendToBackend({
type: 'start_loop',
intervalMs: Number(intervalInput?.value || 5000),
})
})
stopBtn?.addEventListener('click', () => {
ctx.sendToBackend({ type: 'stop_loop' })
})
const unsubBackend = ctx.onBackendMessage((payload: any) => {
if (payload?.type === 'watchdog_status' && statusEl) {
statusEl.textContent = `Status: ${payload.state}${payload.exitReason ? ` (${payload.exitReason})` : ''}${payload.error ? ` - ${payload.error}` : ''}`
}
if (payload?.type === 'watchdog_tick' && ticksEl) {
tickCount = Number(payload.count) || tickCount + 1
ticksEl.textContent = `Ticks: ${tickCount}`
}
})
return () => {
unregisterProcess()
unsubBackend()
tab.destroy()
}
}
How It Works¶
- The frontend registers a
watchdog-loophandler withctx.processes.register(...). - The user clicks Start, and the frontend sends a simple
start_loopmessage to the backend. - The backend calls
spindle.frontendProcesses.spawn(...)with startup and heartbeat timeouts. - The frontend process calls
process.ready(), then emitsprocess.heartbeat()on every interval tick. - Every tick is sent back to the backend with
process.send(...), and the backend forwards a UI update withspindle.sendToFrontend(...). - If the user clicks Stop, the backend requests shutdown with
spindle.frontendProcesses.stop(...), the frontend receivesprocess.onStop(...), clears its timer, and callsprocess.complete(). - If the loop freezes and stops heartbeating, the host marks it
timed_outand emits that lifecycle event back into the backend worker.
Why This Pattern Matters¶
This gives you a backend-owned lifecycle for frontend work without exposing raw browser process.* semantics.
Use it when you need:
- a sync loop the backend can monitor
- a frozen-script watchdog
- graceful stop/restart behavior
- per-user frontend workers with explicit lifecycle events
For ordinary one-shot UI messages, the simpler ctx.sendToBackend() / spindle.sendToFrontend() path is still the right tool.