Service registry

service.mjs

A page's modules and controller objects set timers: a toast's dismiss countdown, a debounced save, a poll, a backup limit on a request. Each part that sets a timer registers a stop with Service, so that one call, Service.stop_all(), stops them all. A test suite uses it to end its run as soon as its last test passes, instead of waiting for the longest timer to fire.

Service holds no timers. Each module keeps its own timers and clears them in its own stop, next to the code that sets them.

When to register

API

MemberSignatureDescription
Service.add (name, { stop }) => void Register a service. stop is required; a call without one throws a TypeError. name identifies the service in a failure report, and several instances may share one.
Service.stop_all () => Promise<void> Stop every registered service, including any that register while the others stop. Every service is stopped even when one fails; the failures are then thrown together as an AggregateError. Services keep working afterwards and can be stopped again.

What stopping does

Every registered stop is called, all at once and in no order. A stop sends the work its service holds, then clears its timers and removes its page listeners. Because nothing orders the calls, a stop must not rely on another service still running.

Example

import Service from '/ui/lib/service.mjs'

// A module that shows a banner for a few seconds. Each call sets a timer, so the module keeps
// a set of the ones still waiting.
const hiding = new Set()
Service.add('banner', {
  stop: () => {
    for (const timer of hiding) clearTimeout(timer)
    hiding.clear()
  },
})
export function show(text) {
  render(text)
  const timer = setTimeout(() => { hiding.delete(timer); hide() }, 4000)
  hiding.add(timer)
}

// A settings panel that saves a moment after the last change, straight to the server. Its
// stop sends a change still waiting rather than drop it.
class Settings {
  constructor(api) {
    this._api = api
    Service.add('settings', { stop: () => this.stop() })
  }
  change(values) {
    this._values = values
    clearTimeout(this._timer)
    this._timer = setTimeout(() => { this._timer = null; this._send() }, 400)
  }
  stop() {
    if (!this._timer) return
    clearTimeout(this._timer)
    this._timer = null
    return this._send()
  }
  _send() { return this._api.patch('/settings', this._values) }
}

// A draft that must survive the tab closing: the job queue holds the delay, so the draft is
// stored at once and there is no timer, and no stop, here.
function edit_draft(queue, text) {
  queue.cancel_group('draft')
  queue.enqueue_fetch({ group_id: 'draft', method: 'PUT', path: '/draft', body: { text }, delay_ms: 3000 })
}

In tests

A timer is an ordinary Node timer under a test runner, and closing a simulated window does not clear it, so one pending dismiss countdown keeps a finished test file running for seconds. Call Service.stop_all() in the suite's teardown, before closing the window, so every service can still reach the document while it stops.

Page hide and restore

Service does not listen to page events. A browser freezes a page's timers while the page waits in the back/forward cache and resumes them when the reader returns, so most services need nothing there. A service that must act when the page is hidden, such as sending a pending save before the tab closes, adds its own pagehide and pageshow listeners.