@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 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'