@greendrake/service-state 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @greendrake/service-state
2
2
 
3
- A backend service an operator can turn on and off, as a state machine both halves of a dashboard share. The root entry is the contract — the states, the status, the method and push names, the transition table — and is pure types and data with no runtime dependency, so the frontend importing it pulls in nothing. `./server` is the machine behind it.
3
+ Backend services an operator can turn on and off, each a state machine both halves of a dashboard share, any number of them over one socket. The root entry is the contract — the states, the status, the method and push names, the transition table — and is pure types and data with no runtime dependency, so the frontend importing it pulls in nothing. `./server` is the machine behind it.
4
4
 
5
5
  ```
6
6
  OFF --start--> STARTING --> ON ON --stop--> STOPPING --> OFF
@@ -24,37 +24,37 @@ The `./server` entry needs `@greendrake/rpc-server`, which is an optional peer d
24
24
  import { SERVICE_STATE_EVENT, type ServiceStateMethods, type ServiceStatePushes, type ServiceStatus } from '@greendrake/service-state'
25
25
 
26
26
  interface ServiceStatus {
27
+ service: string // which service this is the status of
27
28
  state: 'OFF' | 'STARTING' | 'ON' | 'STOPPING' | 'ERROR'
28
29
  error: string | null // what went wrong, in ERROR; null everywhere else
29
30
  changed_at: number // epoch ms
30
31
  }
31
32
  ```
32
33
 
33
- `ServiceStateMethods` is the four methods (`service.status`, `service.start`, `service.stop`, `service.refresh`), each answering a `ServiceStatus` — hand it to an `ApiClient`. `ServiceStatePushes` types the `service.state` event, which carries the same status to **every** connected socket: a service has one state, and everyone watching it is watching the same thing.
34
-
35
- `@greendrake/dash`'s `ServiceStateControl` renders all of this; a dashboard that wants the control needs no code of its own beyond wiring it to its client and transport.
34
+ `ServiceStateMethods` is the four methods (`service.status`, `service.start`, `service.stop`, `service.refresh`), each taking a `ServiceRef` — `{ service }`, the name of the service it is about — and answering its `ServiceStatus`; hand it to a typed client. `ServiceStatePushes` types the `service.state` event, which carries a status to **every** connected socket: a service has one state, and everyone watching it is watching the same thing. The status names its service, so a socket watching several tells their pushes apart by it.
36
35
 
37
36
  ## The machine
38
37
 
39
38
  ```ts
40
39
  import { createServiceStateMachine, serviceStateMethods } from '@greendrake/service-state/server'
41
40
 
42
- const machine = await createServiceStateMachine(
43
- {
44
- start: () => systemd.start('thing.service'),
45
- stop: () => systemd.stop('thing.service'),
46
- check: () => systemd.isActive('thing.service').then(on => (on ? 'ON' : 'OFF'))
47
- },
48
- registry // any EventSink — @greendrake/rpc-server's ConnRegistry is one
49
- )
41
+ const unit = (name: string) => ({
42
+ start: () => systemd.start(name),
43
+ stop: () => systemd.stop(name),
44
+ check: () => systemd.isActive(name).then(on => (on ? 'ON' : 'OFF'))
45
+ })
46
+
47
+ // Each machine is named for the wire; the registry — any EventSink, and
48
+ // @greendrake/rpc-server's ConnRegistry is one — is where its pushes go.
49
+ const machines = await Promise.all([createServiceStateMachine('web', unit('web.service'), registry), createServiceStateMachine('worker', unit('worker.service'), registry)])
50
50
 
51
- const methods = { ...serviceStateMethods(machine), ...whateverElse }
51
+ const methods = { ...serviceStateMethods(machines), ...whateverElse }
52
52
  ```
53
53
 
54
- The factory reads the service before returning, so the machine never reports a state it has not verified — one that assumed `OFF` at boot would have every dashboard showing `OFF` for a service that is up.
54
+ The name is what a call's `{ service }` reaches the machine by and what its every status carries. The factory reads the service before returning, so the machine never reports a state it has not verified — one that assumed `OFF` at boot would have every dashboard showing `OFF` for a service that is up.
55
55
 
56
56
  `start` and `stop` **return as soon as the transition has begun**, with the in-flight status. Where it ends up is the machine's next state, pushed to everyone — not the caller's answer, because everyone watching needs it equally and one of them happening to have asked changes nothing. A hook that rejects lands the machine in `ERROR` carrying its message.
57
57
 
58
58
  One transition at a time, and each only from the state it is defined for. Anything else — `start` while already `ON`, `refresh` mid-transition — is refused with `SERVICE_BUSY`. A client showing the state never triggers it, because the state it is showing offers no such button.
59
59
 
60
- `serviceStateMethods(machine, auth = 'admin')` returns the four `MethodDef`s. Admin-only by default: turning a service off is not a read.
60
+ `serviceStateMethods(machines, auth = 'admin')` returns the four `MethodDef`s over every machine given, so one dispatch table — and one socket — serves them all. A call's argument is checked for its shape before any machine is reached (`INVALID_ARGUMENT` on `service`); one naming a service the table does not serve is refused with `SERVICE_NOT_FOUND` — a `_NOT_FOUND` code, which clients following `@greendrake/rpc-server`'s convention treat as not found. Two machines under one name are refused as the table is built. Admin-only by default: turning a service off is not a read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/service-state",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A backend service as a state machine (OFF/STARTING/ON/STOPPING/ERROR): the wire contract both halves share, and the server-side machine behind it",
5
5
  "license": "MIT",
6
6
  "author": "Eugene Greendrake <eugene@greendrake.nz>",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "prettier": "@greendrake/dev-config/prettier",
40
40
  "peerDependencies": {
41
- "@greendrake/rpc-server": "0.2.0"
41
+ "@greendrake/rpc-server": "0.4.0"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@greendrake/rpc-server": {
@@ -46,8 +46,8 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
- "@greendrake/dev-config": "0.2.0",
50
- "@greendrake/rpc-server": "0.2.0",
49
+ "@greendrake/dev-config": "0.4.0",
50
+ "@greendrake/rpc-server": "0.4.0",
51
51
  "@types/bun": "^1.3.5",
52
52
  "typescript": "6.0.3"
53
53
  }
package/src/main.ts CHANGED
@@ -7,8 +7,17 @@ export const SERVICE_STATES = ['OFF', 'STARTING', 'ON', 'STOPPING', 'ERROR'] as
7
7
 
8
8
  export type ServiceState = (typeof SERVICE_STATES)[number]
9
9
 
10
+ // Which service a call is about. Every method takes it, so one socket serves
11
+ // any number of services side by side.
12
+ export interface ServiceRef {
13
+ service: string
14
+ }
15
+
10
16
  // Wire shape: snake_case, as the envelope's own fields are.
11
17
  export interface ServiceStatus {
18
+ // The service this is the status of — what a push is told apart by, since
19
+ // every service's pushes arrive on the same socket.
20
+ service: string
12
21
  state: ServiceState
13
22
  // What went wrong, in ERROR. Null in every other state — a stale message
14
23
  // outliving the failure it describes is worse than none.
@@ -39,14 +48,15 @@ export type ServiceCommand = keyof typeof TRANSITIONS
39
48
  export const SERVICE_STATE_EVENT = 'service.state'
40
49
 
41
50
  // The methods a service-state backend serves. A dashboard hands this to its
42
- // ApiClient; the server derives its definitions from it.
51
+ // typed client; the server derives its definitions from it.
43
52
  export interface ServiceStateMethods {
44
- 'service.status': () => ServiceStatus
45
- 'service.start': () => ServiceStatus
46
- 'service.stop': () => ServiceStatus
47
- 'service.refresh': () => ServiceStatus
53
+ 'service.status': (ref: ServiceRef) => ServiceStatus
54
+ 'service.start': (ref: ServiceRef) => ServiceStatus
55
+ 'service.stop': (ref: ServiceRef) => ServiceStatus
56
+ 'service.refresh': (ref: ServiceRef) => ServiceStatus
48
57
  }
49
58
 
50
59
  // Every state change reaches every connected socket: a service has one state,
51
- // and everyone watching it is watching the same thing.
60
+ // and everyone watching it is watching the same thing. The status names its
61
+ // service, so a socket watching several follows each by its name.
52
62
  export type ServiceStatePushes = { [SERVICE_STATE_EVENT]: ServiceStatus }
package/src/server.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ApiError } from '@greendrake/rpc-server'
2
2
  import type { AuthRequirement, EventSink, MethodDef } from '@greendrake/rpc-server'
3
3
  import { SERVICE_STATE_EVENT, TRANSITIONS } from './main'
4
- import type { ServiceCommand, ServiceState, ServiceStateMethods, ServiceStatus } from './main'
4
+ import type { ServiceCommand, ServiceRef, ServiceState, ServiceStateMethods, ServiceStatus } from './main'
5
5
 
6
6
  // What a concrete service plugs in. `start` and `stop` resolve once the
7
7
  // service is up or down; rejecting puts the machine in ERROR with the
@@ -14,6 +14,9 @@ export interface ServiceImplementation {
14
14
  }
15
15
 
16
16
  export interface ServiceStateMachine {
17
+ // What the service is called on the wire: what a call names to reach it,
18
+ // and what each of its pushes carries.
19
+ readonly name: string
17
20
  status(): ServiceStatus
18
21
  // Return as soon as the transition has begun, with the in-flight status.
19
22
  // Where it ends up is the machine's next state, pushed to everyone — not
@@ -31,12 +34,17 @@ export interface ServiceStateMachine {
31
34
  // what a client that ignored the state gets told.
32
35
  const BUSY = 'SERVICE_BUSY'
33
36
 
37
+ // A call naming a service the table does not serve. A `_NOT_FOUND` code, so
38
+ // clients following rpc-server's convention treat it as not found.
39
+ const NOT_FOUND = 'SERVICE_NOT_FOUND'
40
+
34
41
  // The service-state machine over a concrete implementation. Reads the service
35
42
  // before returning, so it never reports a state it has not verified — a
36
43
  // machine that assumed OFF at boot would have the dashboard show OFF for a
37
44
  // service that is up.
38
- export const createServiceStateMachine = async (service: ServiceImplementation, sink: EventSink): Promise<ServiceStateMachine> => {
45
+ export const createServiceStateMachine = async (name: string, service: ServiceImplementation, sink: EventSink): Promise<ServiceStateMachine> => {
39
46
  let current: ServiceStatus = {
47
+ service: name,
40
48
  state: 'OFF',
41
49
  error: null,
42
50
  changed_at: Date.now()
@@ -45,6 +53,7 @@ export const createServiceStateMachine = async (service: ServiceImplementation,
45
53
 
46
54
  const set = (state: ServiceState, error: string | null): ServiceStatus => {
47
55
  current = {
56
+ service: name,
48
57
  state,
49
58
  error,
50
59
  changed_at: Date.now()
@@ -90,6 +99,7 @@ export const createServiceStateMachine = async (service: ServiceImplementation,
90
99
  }
91
100
 
92
101
  const machine: ServiceStateMachine = {
102
+ name,
93
103
  status: () => current,
94
104
  start: () => run('start'),
95
105
  stop: () => run('stop'),
@@ -101,16 +111,48 @@ export const createServiceStateMachine = async (service: ServiceImplementation,
101
111
 
102
112
  // Derived from the client-facing contract rather than restated beside it: a
103
113
  // method added there has to be defined here before this compiles.
104
- export type ServiceStateMethodDefs = { [K in keyof ServiceStateMethods]: MethodDef<void, ReturnType<ServiceStateMethods[K]>> }
114
+ export type ServiceStateMethodDefs = { [K in keyof ServiceStateMethods]: MethodDef<Parameters<ServiceStateMethods[K]>[0], ReturnType<ServiceStateMethods[K]>> }
115
+
116
+ // The argument every method takes, checked for its shape. Whether it names a
117
+ // service the table serves is the table's to say, as SERVICE_NOT_FOUND. Written
118
+ // against Standard Schema directly, so this package carries no validator.
119
+ const SERVICE_REF: NonNullable<MethodDef<ServiceRef>['args']> = {
120
+ '~standard': {
121
+ version: 1,
122
+ vendor: 'greendrake',
123
+ validate: value => (typeof value === 'object' && value !== null && 'service' in value && typeof value.service === 'string' ? { value: { service: value.service } } : { issues: [{ message: 'Expected the name of a service', path: ['service'] }] })
124
+ }
125
+ }
105
126
 
106
- // The four methods, ready to merge into a dispatch table. Admin-only by
107
- // default: turning a service off is not a read.
108
- export const serviceStateMethods = (machine: ServiceStateMachine, auth: AuthRequirement = 'admin'): ServiceStateMethodDefs => ({
109
- 'service.status': { auth, handler: () => machine.status() },
110
- 'service.start': { auth, handler: () => machine.start() },
111
- 'service.stop': { auth, handler: () => machine.stop() },
112
- 'service.refresh': { auth, handler: () => machine.refresh() }
113
- })
127
+ // The four methods over every machine given, ready to merge into a dispatch
128
+ // table: one socket serves them all, each call naming its service. Admin-only
129
+ // by default: turning a service off is not a read.
130
+ export const serviceStateMethods = (machines: readonly ServiceStateMachine[], auth: AuthRequirement = 'admin'): ServiceStateMethodDefs => {
131
+ const byName = new Map(machines.map(machine => [machine.name, machine]))
132
+ if (byName.size !== machines.length) {
133
+ throw new Error(`serviceStateMethods: two machines share a name among ${machines.map(machine => machine.name).join(', ')}`)
134
+ }
135
+ // Each method is the same three steps — the argument checked, the service
136
+ // it names found, one thing asked of that service's machine — and differs
137
+ // only in what is asked.
138
+ const asking = (ask: (machine: ServiceStateMachine) => ServiceStatus | Promise<ServiceStatus>): MethodDef<ServiceRef, ServiceStatus> => ({
139
+ auth,
140
+ args: SERVICE_REF,
141
+ handler: (_ctx, { service }) => {
142
+ const machine = byName.get(service)
143
+ if (!machine) {
144
+ throw new ApiError(NOT_FOUND)
145
+ }
146
+ return ask(machine)
147
+ }
148
+ })
149
+ return {
150
+ 'service.status': asking(machine => machine.status()),
151
+ 'service.start': asking(machine => machine.start()),
152
+ 'service.stop': asking(machine => machine.stop()),
153
+ 'service.refresh': asking(machine => machine.refresh())
154
+ }
155
+ }
114
156
 
115
157
  export { SERVICE_STATE_EVENT, SERVICE_STATES, TRANSITIONS } from './main'
116
- export type { ServiceCommand, ServiceState, ServiceStateMethods, ServiceStatePushes, ServiceStatus } from './main'
158
+ export type { ServiceCommand, ServiceRef, ServiceState, ServiceStateMethods, ServiceStatePushes, ServiceStatus } from './main'