@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 +15 -15
- package/package.json +4 -4
- package/src/main.ts +16 -6
- package/src/server.ts +54 -12
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @greendrake/service-state
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
50
|
-
"@greendrake/rpc-server": "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
|
-
//
|
|
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<
|
|
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
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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'
|