@greendrake/service-state 0.1.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/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +54 -0
- package/src/main.ts +52 -0
- package/src/server.ts +116 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Greendrake
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @greendrake/service-state
|
|
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.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
OFF --start--> STARTING --> ON ON --stop--> STOPPING --> OFF
|
|
7
|
+
\ \
|
|
8
|
+
--> ERROR --> ERROR
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`refresh` runs from any settled state and adopts what the service actually reports.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add @greendrake/service-state
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The `./server` entry needs `@greendrake/rpc-server`, which is an optional peer dependency: a dashboard importing only the contract installs neither it nor bun's types.
|
|
20
|
+
|
|
21
|
+
## The contract
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { SERVICE_STATE_EVENT, type ServiceStateMethods, type ServiceStatePushes, type ServiceStatus } from '@greendrake/service-state'
|
|
25
|
+
|
|
26
|
+
interface ServiceStatus {
|
|
27
|
+
state: 'OFF' | 'STARTING' | 'ON' | 'STOPPING' | 'ERROR'
|
|
28
|
+
error: string | null // what went wrong, in ERROR; null everywhere else
|
|
29
|
+
changed_at: number // epoch ms
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
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.
|
|
36
|
+
|
|
37
|
+
## The machine
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createServiceStateMachine, serviceStateMethods } from '@greendrake/service-state/server'
|
|
41
|
+
|
|
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
|
+
)
|
|
50
|
+
|
|
51
|
+
const methods = { ...serviceStateMethods(machine), ...whateverElse }
|
|
52
|
+
```
|
|
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.
|
|
55
|
+
|
|
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
|
+
|
|
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
|
+
|
|
60
|
+
`serviceStateMethods(machine, auth = 'admin')` returns the four `MethodDef`s. Admin-only by default: turning a service off is not a read.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@greendrake/service-state",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/greendrake/lib.git",
|
|
10
|
+
"directory": "service-state"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/greendrake/lib/tree/main/service-state#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/greendrake/lib/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/main.ts",
|
|
18
|
+
"types": "src/main.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/main.ts",
|
|
22
|
+
"default": "./src/main.ts"
|
|
23
|
+
},
|
|
24
|
+
"./server": {
|
|
25
|
+
"types": "./src/server.ts",
|
|
26
|
+
"default": "./src/server.ts"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"check": "tsc --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"prettier": "@greendrake/dev-config/prettier",
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@greendrake/rpc-server": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@greendrake/rpc-server": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@greendrake/dev-config": "0.1.0",
|
|
50
|
+
"@greendrake/rpc-server": "0.1.0",
|
|
51
|
+
"@types/bun": "^1.3.5",
|
|
52
|
+
"typescript": "6.0.3"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The contract a service-state pair shares: the states, the status both ends
|
|
2
|
+
// pass around, the method and push names, and the transition table. Pure types
|
|
3
|
+
// and data — no runtime dependency at all, so a frontend importing it pulls in
|
|
4
|
+
// nothing. The machine behind it is the `./server` entry.
|
|
5
|
+
|
|
6
|
+
export const SERVICE_STATES = ['OFF', 'STARTING', 'ON', 'STOPPING', 'ERROR'] as const
|
|
7
|
+
|
|
8
|
+
export type ServiceState = (typeof SERVICE_STATES)[number]
|
|
9
|
+
|
|
10
|
+
// Wire shape: snake_case, as the envelope's own fields are.
|
|
11
|
+
export interface ServiceStatus {
|
|
12
|
+
state: ServiceState
|
|
13
|
+
// What went wrong, in ERROR. Null in every other state — a stale message
|
|
14
|
+
// outliving the failure it describes is worse than none.
|
|
15
|
+
error: string | null
|
|
16
|
+
// When the service entered this state, epoch milliseconds.
|
|
17
|
+
changed_at: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Each command: the state it is accepted from, the state the service passes
|
|
21
|
+
// through while it runs, and where it lands. The machine refuses anything this
|
|
22
|
+
// table does not cover, and the control derives its button from it — so adding
|
|
23
|
+
// a command is one row, not an edit in two packages.
|
|
24
|
+
export const TRANSITIONS = {
|
|
25
|
+
start: {
|
|
26
|
+
from: 'OFF',
|
|
27
|
+
during: 'STARTING',
|
|
28
|
+
to: 'ON'
|
|
29
|
+
},
|
|
30
|
+
stop: {
|
|
31
|
+
from: 'ON',
|
|
32
|
+
during: 'STOPPING',
|
|
33
|
+
to: 'OFF'
|
|
34
|
+
}
|
|
35
|
+
} as const satisfies Record<string, { from: ServiceState; during: ServiceState; to: ServiceState }>
|
|
36
|
+
|
|
37
|
+
export type ServiceCommand = keyof typeof TRANSITIONS
|
|
38
|
+
|
|
39
|
+
export const SERVICE_STATE_EVENT = 'service.state'
|
|
40
|
+
|
|
41
|
+
// The methods a service-state backend serves. A dashboard hands this to its
|
|
42
|
+
// ApiClient; the server derives its definitions from it.
|
|
43
|
+
export interface ServiceStateMethods {
|
|
44
|
+
'service.status': () => ServiceStatus
|
|
45
|
+
'service.start': () => ServiceStatus
|
|
46
|
+
'service.stop': () => ServiceStatus
|
|
47
|
+
'service.refresh': () => ServiceStatus
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Every state change reaches every connected socket: a service has one state,
|
|
51
|
+
// and everyone watching it is watching the same thing.
|
|
52
|
+
export type ServiceStatePushes = { [SERVICE_STATE_EVENT]: ServiceStatus }
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ApiError } from '@greendrake/rpc-server'
|
|
2
|
+
import type { AuthRequirement, EventSink, MethodDef } from '@greendrake/rpc-server'
|
|
3
|
+
import { SERVICE_STATE_EVENT, TRANSITIONS } from './main'
|
|
4
|
+
import type { ServiceCommand, ServiceState, ServiceStateMethods, ServiceStatus } from './main'
|
|
5
|
+
|
|
6
|
+
// What a concrete service plugs in. `start` and `stop` resolve once the
|
|
7
|
+
// service is up or down; rejecting puts the machine in ERROR with the
|
|
8
|
+
// rejection's message. `check` reports what the service is actually doing
|
|
9
|
+
// right now, read from the service rather than remembered.
|
|
10
|
+
export interface ServiceImplementation {
|
|
11
|
+
start(): Promise<void>
|
|
12
|
+
stop(): Promise<void>
|
|
13
|
+
check(): Promise<ServiceState>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ServiceStateMachine {
|
|
17
|
+
status(): ServiceStatus
|
|
18
|
+
// Return as soon as the transition has begun, with the in-flight status.
|
|
19
|
+
// Where it ends up is the machine's next state, pushed to everyone — not
|
|
20
|
+
// this caller's answer, because everyone watching needs it equally and one
|
|
21
|
+
// of them happening to have asked changes nothing.
|
|
22
|
+
start(): ServiceStatus
|
|
23
|
+
stop(): ServiceStatus
|
|
24
|
+
// Re-read the service and adopt what it reports.
|
|
25
|
+
refresh(): Promise<ServiceStatus>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The command a state is in no position to take. One code covers both cases —
|
|
29
|
+
// a transition already under way, and a command that would take the service
|
|
30
|
+
// where it already is — because the control never offers either, so this is
|
|
31
|
+
// what a client that ignored the state gets told.
|
|
32
|
+
const BUSY = 'SERVICE_BUSY'
|
|
33
|
+
|
|
34
|
+
// The service-state machine over a concrete implementation. Reads the service
|
|
35
|
+
// before returning, so it never reports a state it has not verified — a
|
|
36
|
+
// machine that assumed OFF at boot would have the dashboard show OFF for a
|
|
37
|
+
// service that is up.
|
|
38
|
+
export const createServiceStateMachine = async (service: ServiceImplementation, sink: EventSink): Promise<ServiceStateMachine> => {
|
|
39
|
+
let current: ServiceStatus = {
|
|
40
|
+
state: 'OFF',
|
|
41
|
+
error: null,
|
|
42
|
+
changed_at: Date.now()
|
|
43
|
+
}
|
|
44
|
+
let inFlight = false
|
|
45
|
+
|
|
46
|
+
const set = (state: ServiceState, error: string | null): ServiceStatus => {
|
|
47
|
+
current = {
|
|
48
|
+
state,
|
|
49
|
+
error,
|
|
50
|
+
changed_at: Date.now()
|
|
51
|
+
}
|
|
52
|
+
sink.broadcastEvent(SERVICE_STATE_EVENT, current)
|
|
53
|
+
return current
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// What a re-read yields: a push only where something actually changed, so
|
|
57
|
+
// a poll against a steady service is silent.
|
|
58
|
+
const adopt = (state: ServiceState, error: string | null): ServiceStatus => (state === current.state && error === current.error ? current : set(state, error))
|
|
59
|
+
|
|
60
|
+
const run = (command: ServiceCommand): ServiceStatus => {
|
|
61
|
+
const { from, during, to } = TRANSITIONS[command]
|
|
62
|
+
if (inFlight || current.state !== from) {
|
|
63
|
+
throw new ApiError(BUSY)
|
|
64
|
+
}
|
|
65
|
+
inFlight = true
|
|
66
|
+
const answer = set(during, null)
|
|
67
|
+
void service[command]()
|
|
68
|
+
.then(
|
|
69
|
+
() => set(to, null),
|
|
70
|
+
(e: Error) => set('ERROR', e.message)
|
|
71
|
+
)
|
|
72
|
+
.finally(() => {
|
|
73
|
+
inFlight = false
|
|
74
|
+
})
|
|
75
|
+
return answer
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const refresh = async (): Promise<ServiceStatus> => {
|
|
79
|
+
if (inFlight) {
|
|
80
|
+
throw new ApiError(BUSY)
|
|
81
|
+
}
|
|
82
|
+
inFlight = true
|
|
83
|
+
try {
|
|
84
|
+
return adopt(await service.check(), null)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return adopt('ERROR', (e as Error).message)
|
|
87
|
+
} finally {
|
|
88
|
+
inFlight = false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const machine: ServiceStateMachine = {
|
|
93
|
+
status: () => current,
|
|
94
|
+
start: () => run('start'),
|
|
95
|
+
stop: () => run('stop'),
|
|
96
|
+
refresh
|
|
97
|
+
}
|
|
98
|
+
await machine.refresh()
|
|
99
|
+
return machine
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Derived from the client-facing contract rather than restated beside it: a
|
|
103
|
+
// method added there has to be defined here before this compiles.
|
|
104
|
+
export type ServiceStateMethodDefs = { [K in keyof ServiceStateMethods]: MethodDef<void, ReturnType<ServiceStateMethods[K]>> }
|
|
105
|
+
|
|
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
|
+
})
|
|
114
|
+
|
|
115
|
+
export { SERVICE_STATE_EVENT, SERVICE_STATES, TRANSITIONS } from './main'
|
|
116
|
+
export type { ServiceCommand, ServiceState, ServiceStateMethods, ServiceStatePushes, ServiceStatus } from './main'
|