Skip to content

Frontend Process Lifecycle

Spawn and supervise long-lived frontend-side controllers from your backend runtime.

This is the structured version of backend/frontend messaging: the backend owns the lifecycle, the frontend acknowledges startup, emits heartbeats, and can send process-scoped messages back.

No permission is required. This is a free-tier API.

When To Use This

Use spindle.frontendProcesses when you need frontend work that is:

  • long-lived rather than one-shot
  • user-scoped
  • observable from the backend
  • restartable or replaceable
  • protected by a startup or heartbeat watchdog

Examples:

  • sync loops
  • watchdog-monitored DOM observers
  • route-aware UI coordinators
  • stateful frontend workers that need backend supervision

For simple request/response messaging, use Frontend Communication instead.

Quick Start

const process = await spindle.frontendProcesses.spawn({
  kind: 'sync-loop',
  key: `chat:${chatId}`,
  userId,
  payload: { chatId },
  startupTimeoutMs: 10_000,
  heartbeatTimeoutMs: 15_000,
  replaceExisting: true,
})

const unsubLifecycle = spindle.frontendProcesses.onLifecycle((event) => {
  if (event.processId !== process.processId) return

  if (event.state === 'timed_out') {
    spindle.log.warn(`Frontend process timed out: ${event.kind}`)
  }

  if (event.state === 'failed') {
    spindle.log.error(event.error ?? 'Frontend process failed')
  }
})

const unsubMessages = spindle.frontendProcesses.onMessage((event) => {
  if (event.processId !== process.processId) return

  if ((event.payload as any)?.type === 'tick') {
    spindle.log.info(`Tick from ${event.userId}`)
  }
})

process.send({ type: 'set_interval', ms: 2000 })

// Later
await process.stop({ reason: 'chat_changed' })
unsubLifecycle()
unsubMessages()

Methods

spindle.frontendProcesses.spawn(options)

Spawn a frontend process and wait for the frontend handler to call process.ready().

If the frontend never acknowledges readiness before startupTimeoutMs, the spawn rejects and the process transitions to timed_out.

const process = await spindle.frontendProcesses.spawn({
  kind: 'presence-loop',
  userId,
  payload: { chatId: 'abc' },
})
Field Type Default Description
kind string required Frontend handler key registered with ctx.processes.register(kind, handler)
key string Optional stable dedupe key
payload unknown Arbitrary spawn payload delivered to the frontend handler
metadata Record<string, unknown> Host-tracked metadata snapshot
userId string installer for user-scoped extensions Required for operator-scoped extensions
startupTimeoutMs number 15000 Time budget for process.ready()
heartbeatTimeoutMs number 15000 Max allowed gap between heartbeats after ready
replaceExisting boolean false Replace an existing process with the same kind + key for the target user

The returned handle exposes:

Property / Method Returns Description
processId string Host-assigned process ID
kind string Spawned frontend kind
key string? Optional stable key
info FrontendProcessInfoDTO Snapshot returned at spawn time
send(payload) void Send a process-scoped message to the frontend instance
stop(options?) Promise<void> Request graceful termination
refresh() Promise<FrontendProcessInfoDTO \| null> Fetch the latest snapshot

spindle.frontendProcesses.list(filter?)

List tracked frontend processes for this extension.

const running = await spindle.frontendProcesses.list({
  userId,
  kind: 'sync-loop',
  state: 'running',
})

spindle.frontendProcesses.get(processId)

Fetch one process by ID.

const info = await spindle.frontendProcesses.get(processId)
if (info?.state === 'running') {
  spindle.log.info(`Heartbeat at ${info.lastHeartbeatAt}`)
}

spindle.frontendProcesses.send(processId, payload, userId?)

Send a process-scoped message directly to a running frontend process.

spindle.frontendProcesses.send(processId, {
  type: 'config_update',
  config: { mode: 'fast' }
});

spindle.frontendProcesses.stop(processId, options?)

Request graceful termination for a process.

await spindle.frontendProcesses.stop(processId, {
  userId,
  reason: 'panel_closed',
})

spindle.frontendProcesses.onLifecycle(handler)

Receive lifecycle transitions for every tracked frontend process owned by the extension.

const unsub = spindle.frontendProcesses.onLifecycle((event) => {
  spindle.log.info(`${event.kind} -> ${event.state}`)
})

Lifecycle states:

  • starting
  • running
  • stopping
  • stopped
  • completed
  • failed
  • timed_out

Common exit reasons:

  • completed
  • failed
  • stopped
  • timed_out
  • frontend_unloaded
  • backend_unloaded
  • replaced

spindle.frontendProcesses.onMessage(handler)

Receive process-scoped messages sent from the frontend side.

const unsub = spindle.frontendProcesses.onMessage((event) => {
  if (event.processId !== processId) return
  spindle.log.info(JSON.stringify(event.payload))
})

Multi-User Behavior

  • User-scoped extensions always target their installer.
  • Operator-scoped extensions must pass userId when spawning user-specific processes.
  • replaceExisting is scoped by userId + kind + key.

Watchdog Model

spawn() waits for the frontend to call process.ready().

After that, the host expects periodic process.heartbeat() calls if heartbeatTimeoutMs is greater than 0.

If the frontend:

  • never becomes ready, the process times out during startup
  • stops heartbeating, the process transitions to timed_out
  • unloads because the extension frontend is torn down, the process transitions to frontend_unloaded

Use ordinary backend/frontend messaging for stateless UI requests, and use frontend processes for stateful loops.

Typical split:

  • spindle.sendToFrontend() / ctx.sendToBackend() for one-shot messages
  • spindle.frontendProcesses.spawn() for supervised, user-scoped long-running work