@hardlydifficult/worker-server 1.0.4 → 1.0.6

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.
Files changed (2) hide show
  1. package/README.md +300 -136
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hardlydifficult/worker-server
2
2
 
3
- A WebSocket-based worker server that manages remote worker connections, health monitoring, request routing, and load balancing through a clean TypeScript interface.
3
+ WebSocket-based worker server with health monitoring, request routing, and load balancing.
4
4
 
5
5
  ## Installation
6
6
 
@@ -15,218 +15,388 @@ import { WorkerServer } from "@hardlydifficult/worker-server";
15
15
 
16
16
  const server = new WorkerServer({
17
17
  port: 8080,
18
- heartbeatTimeoutMs: 60000, // 60 seconds
19
- healthCheckIntervalMs: 10000, // 10 seconds
18
+ authToken: "my-secret-token", // optional
19
+ logger: console, // optional, defaults to no-op
20
20
  });
21
21
 
22
+ // Handle worker registrations and messages
22
23
  server.onWorkerConnected((worker) => {
23
- console.log(`Worker connected: ${worker.name} (${worker.id})`);
24
+ console.log(`Worker ${worker.name} (${worker.id}) connected`);
24
25
  });
25
26
 
26
- server.onWorkerDisconnected((worker, pendingRequests) => {
27
- console.log(`Worker disconnected: ${worker.name} (${worker.id})`);
28
- if (pendingRequests.size > 0) {
29
- console.log(`Rescheduling ${pendingRequests.size} pending requests`);
30
- }
27
+ server.onWorkerDisconnected((worker, pendingRequestIds) => {
28
+ console.log(`Worker ${worker.id} disconnected with ${pendingRequestIds.size} pending requests`);
31
29
  });
32
30
 
33
31
  server.onWorkerMessage("work_complete", (worker, message) => {
34
- console.log(`Work complete: ${message.requestId}`);
32
+ console.log(`Work completed: ${message.requestId}`);
35
33
  });
36
34
 
37
- server.addHttpHandler(async (req, res) => {
38
- if (req.url === "/health") {
39
- res.writeHead(200, { "Content-Type": "application/json" });
40
- res.end(JSON.stringify({ status: "ok" }));
41
- return true;
42
- }
43
- return false;
35
+ // Start the server
36
+ await server.start();
37
+
38
+ // Get an available worker supporting a model
39
+ const worker = server.getAvailableWorker("gpt-4");
40
+ if (worker) {
41
+ server.send(worker.id, { type: "work_request", requestId: "req-1" });
42
+ }
43
+
44
+ // Stop when done
45
+ await server.stop();
46
+ ```
47
+
48
+ ## Core Concepts
49
+
50
+ ### Worker Lifecycle Management
51
+
52
+ The server handles worker connections, registrations, and disconnections with automatic health monitoring.
53
+
54
+ #### Registration and Authentication
55
+
56
+ Workers connect via WebSocket and send a `worker_registration` message with an optional `authToken`. If the server is configured with `authToken`, the worker must provide a matching token.
57
+
58
+ ```typescript
59
+ import { WorkerServer } from "@hardlydifficult/worker-server";
60
+
61
+ const server = new WorkerServer({
62
+ port: 8080,
63
+ authToken: "secret",
64
+ });
65
+
66
+ server.onWorkerConnected((worker) => {
67
+ console.log(`Worker registered: ${worker.id}`);
44
68
  });
45
69
 
46
70
  await server.start();
47
- console.log("Worker server running on port 8080");
48
71
  ```
49
72
 
50
- ## Core Concepts
73
+ #### Heartbeat and Health Monitoring
51
74
 
52
- ### Worker Management
75
+ Workers must send periodic `heartbeat` messages. The server tracks the last heartbeat timestamp and closes connections that miss the timeout.
53
76
 
54
- Workers connect via WebSocket, register with capabilities (supported models, concurrency limits), and send periodic heartbeats. The server tracks their status (available, busy, draining, unhealthy) and makes routing decisions based on load and model compatibility.
77
+ ```typescript
78
+ const server = new WorkerServer({
79
+ port: 8080,
80
+ heartbeatTimeoutMs: 60_000, // Missed for 60s → unhealthy
81
+ healthCheckIntervalMs: 10_000, // Check every 10s
82
+ heartbeatIntervalMs: 15_000, // Communicate 15s interval to workers
83
+ });
84
+ ```
55
85
 
56
- ### Request Tracking
86
+ | Option | Default | Description |
87
+ |--------|---------|-------------|
88
+ | `heartbeatTimeoutMs` | 60000 | Time before worker is marked unhealthy |
89
+ | `healthCheckIntervalMs` | 10000 | Frequency of health checks |
90
+ | `heartbeatIntervalMs` | 15000 | Heartbeat interval communicated to workers |
57
91
 
58
- Requests are tracked per-worker and can be released when complete. Optional categories enable per-category concurrency limits when workers declare `concurrencyLimits` in their capabilities.
92
+ #### Disconnection Handling
59
93
 
60
- ### Load Balancing
94
+ When a worker disconnects, the `onWorkerDisconnected` handler is called with its pending request IDs.
61
95
 
62
- Workers are selected based on:
63
- - Model support (exact or substring match)
64
- - Current load (least-loaded algorithm)
65
- - Category-specific limits (when `category` is provided)
96
+ ```typescript
97
+ server.onWorkerDisconnected((worker, pendingRequestIds) => {
98
+ // Reassign pending requests as needed
99
+ console.log(`Pending: ${[...pendingRequestIds].join(", ")}`);
100
+ });
101
+ ```
66
102
 
67
- ## API Reference
103
+ ### Request Tracking and Load Balancing
68
104
 
69
- ### WorkerServer
105
+ Requests are tracked per-worker to avoid overloading and to support category-specific limits.
70
106
 
71
- Main entry point for managing worker connections.
107
+ #### Request Lifecycle
72
108
 
73
- #### Constructor
109
+ Track a request as assigned to a worker, then release it when complete.
74
110
 
75
111
  ```typescript
76
- constructor(options: WorkerServerOptions)
112
+ // Assign request to worker
113
+ const worker = server.getAvailableWorker("gpt-4");
114
+ if (worker) {
115
+ server.trackRequest(worker.id, "req-1");
116
+ server.send(worker.id, { type: "work_request", requestId: "req-1" });
117
+ }
118
+
119
+ // Worker sends completion message
120
+ server.onWorkerMessage("work_complete", (worker, message) => {
121
+ server.releaseRequest(message.requestId, { incrementCompleted: true });
122
+ });
77
123
  ```
78
124
 
79
- | Option | Type | Default | Description |
80
- |--------|------|---------|-------------|
81
- | `port` | `number` | — | HTTP + WebSocket server port |
82
- | `authToken` | `string` | `undefined` | Optional token required for worker registration |
83
- | `heartbeatTimeoutMs` | `number` | `60000` | Milliseconds before a missed heartbeat marks a worker unhealthy |
84
- | `healthCheckIntervalMs` | `number` | `10000` | Interval (ms) for health checks |
85
- | `heartbeatIntervalMs` | `number` | `15000` | Heartbeat interval communicated to workers |
86
- | `logger` | `WorkerServerLogger` | `undefined` | Logger instance (defaults to no-op) |
125
+ #### Available Worker Selection
87
126
 
88
- #### Lifecycle Events
127
+ Workers are selected based on capacity and model support.
89
128
 
90
129
  ```typescript
91
- // Called when a worker successfully registers
92
- onWorkerConnected(handler: WorkerConnectedHandler): () => void;
130
+ // Get least-loaded worker supporting a model
131
+ const worker = server.getAvailableWorker("gpt-4");
132
+ // → least-loaded worker that supports "gpt-4"
133
+
134
+ // Get any available worker (model-agnostic)
135
+ const anyWorker = server.getAnyAvailableWorker();
136
+ // → any worker (Available or Busy status)
137
+ ```
138
+
139
+ Workers are marked `Busy` when `activeRequests >= maxConcurrentRequests`.
140
+
141
+ #### Per-Category Concurrency Limits
142
+
143
+ Workers can define per-category limits in their capabilities. The pool enforces these when `trackRequest` is called with a category.
93
144
 
94
- // Called when a worker disconnects with pending request IDs
95
- onWorkerDisconnected(handler: WorkerDisconnectedHandler): () => void;
145
+ ```typescript
146
+ // Worker capabilities include:
147
+ {
148
+ models: [{ modelId: "gpt-4", ... }],
149
+ maxConcurrentRequests: 5,
150
+ concurrencyLimits: {
151
+ inference: 2,
152
+ embedding: 4,
153
+ }
154
+ }
96
155
 
97
- // Register a handler for a specific message type (dispatched by 'type' field)
98
- onWorkerMessage<T = Record<string, unknown>>(
99
- type: string,
100
- handler: WorkerMessageHandler<T>
101
- ): () => void;
156
+ // Track request in a category
157
+ server.trackRequest(worker.id, "req-1", "inference");
102
158
  ```
103
159
 
104
- #### Message Operations
160
+ ### Message Routing
161
+
162
+ Messages are routed by the `type` field to registered handlers.
105
163
 
106
164
  ```typescript
107
- // Send a JSON message to a specific worker (false if failed)
108
- send(workerId: string, message: Record<string, unknown>): boolean;
165
+ server.onWorkerMessage("work_complete", (worker, message) => {
166
+ console.log(`Worker ${worker.id} completed ${message.requestId}`);
167
+ });
109
168
 
110
- // Broadcast to all connected workers
111
- broadcast(message: Record<string, unknown>): void;
169
+ server.onWorkerMessage("metrics", (worker, message) => {
170
+ console.log(`Worker ${worker.id} metrics:`, message.metrics);
171
+ });
112
172
  ```
113
173
 
114
- #### Pool Queries
174
+ Returns an unsubscribe function:
115
175
 
116
176
  ```typescript
117
- // Get least-loaded worker supporting the given model
118
- getAvailableWorker(model: string, category?: string): WorkerInfo | null;
177
+ const unsubscribe = server.onWorkerMessage("status", handler);
178
+ // later...
179
+ unsubscribe();
180
+ ```
119
181
 
120
- // Get any available worker (model-agnostic)
121
- getAnyAvailableWorker(): WorkerInfo | null;
182
+ ### Sending Messages
122
183
 
123
- // Total connected worker count
124
- getWorkerCount(): number;
184
+ #### Targeted Send
185
+
186
+ ```typescript
187
+ const success = server.send(workerId, { type: "ping" });
188
+ // Returns false if worker not found or WebSocket not open
189
+ ```
125
190
 
126
- // Available worker count
127
- getAvailableWorkerCount(): number;
191
+ #### Broadcast
128
192
 
129
- // Get info about all connected workers
130
- getWorkerInfo(): WorkerInfo[];
193
+ ```typescript
194
+ server.broadcast({ type: "shutdown" });
195
+ // Sends to all connected workers with open sockets
131
196
  ```
132
197
 
133
- #### Request Tracking
198
+ ### Server Extensibility
199
+
200
+ #### Additional WebSocket Endpoints
134
201
 
135
202
  ```typescript
136
- // Track a request assigned to a worker (optional category)
137
- trackRequest(workerId: string, requestId: string, category?: string): void;
203
+ // Create a custom WebSocket endpoint
204
+ server.addWebSocketEndpoint("/ws/metrics", (ws) => {
205
+ ws.on("message", (data) => {
206
+ console.log("Metrics client message:", data.toString());
207
+ });
208
+ });
138
209
 
139
- // Release a tracked request (optionally increment completed count)
140
- releaseRequest(
141
- requestId: string,
142
- options?: { incrementCompleted?: boolean }
143
- ): void;
210
+ // Clients connect to ws://localhost:8080/ws/metrics
144
211
  ```
145
212
 
146
- #### HTTP & WebSocket Extensibility
213
+ #### HTTP Handlers
147
214
 
148
215
  ```typescript
149
- // Add an HTTP handler (called in order until one returns true)
150
- addHttpHandler(handler: HttpRequestHandler): void;
216
+ server.addHttpHandler(async (req, res) => {
217
+ if (req.url === "/health") {
218
+ res.writeHead(200, { "Content-Type": "application/json" });
219
+ res.end(JSON.stringify({ status: "ok" }));
220
+ return true;
221
+ }
222
+ return false; // continue to next handler or 404
223
+ });
151
224
 
152
- // Add an additional WebSocket endpoint at a path
153
- addWebSocketEndpoint(
154
- path: string,
155
- handler: WebSocketConnectionHandler
156
- ): void;
225
+ // Custom HTTP responses take precedence over 404
157
226
  ```
158
227
 
159
- #### Server Lifecycle
228
+ ## Public API
229
+
230
+ ### `WorkerServer`
231
+
232
+ Main server class for managing worker connections.
233
+
234
+ | Method | Description |
235
+ |--------|-------------|
236
+ | `onWorkerConnected(handler)` | Register handler for worker registration events |
237
+ | `onWorkerDisconnected(handler)` | Register handler for worker disconnection events |
238
+ | `onWorkerMessage(type, handler)` | Register handler for a specific message type |
239
+ | `send(workerId, message)` | Send a JSON message to a specific worker |
240
+ | `broadcast(message)` | Broadcast a JSON message to all workers |
241
+ | `getAvailableWorker(model, category?)` | Get least-loaded worker supporting model |
242
+ | `getAnyAvailableWorker()` | Get any available/Busy worker |
243
+ | `getWorkerCount()` | Total connected worker count |
244
+ | `getAvailableWorkerCount()` | Available worker count |
245
+ | `getWorkerInfo()` | Get public info about all workers |
246
+ | `trackRequest(workerId, requestId, category?)` | Track request as in-progress |
247
+ | `releaseRequest(requestId, options?)` | Release tracked request |
248
+ | `addHttpHandler(handler)` | Add HTTP request handler |
249
+ | `addWebSocketEndpoint(path, handler)` | Add custom WebSocket endpoint |
250
+ | `start()` | Start HTTP + WebSocket server |
251
+ | `stop()` | Stop server and close all connections |
252
+
253
+ ### `WorkerPool`
254
+
255
+ Internal pool manager with public helpers.
256
+
257
+ | Method | Description |
258
+ |--------|-------------|
259
+ | `add(worker)` | Add a connected worker to the pool |
260
+ | `remove(id)` | Remove worker by ID |
261
+ | `get(id)` | Get worker by ID |
262
+ | `has(id)` | Check if worker is in pool |
263
+ | `getAvailableWorker(model, category?)` | Get available worker by model |
264
+ | `getAnyAvailableWorker()` | Get any available/Busy worker |
265
+ | `getCount()` | Total worker count |
266
+ | `getAvailableCount()` | Available worker count |
267
+ | `getWorkerInfoList()` | Get public info for all workers |
268
+ | `checkHealth(timeoutMs)` | Check worker health and return dead IDs |
269
+ | `send(workerId, message)` | Send message to worker |
270
+ | `broadcast(message)` | Broadcast to all workers |
271
+ | `closeAll()` | Close all worker connections |
272
+
273
+ ### `toWorkerInfo(worker)`
274
+
275
+ Converts internal `ConnectedWorker` to public `WorkerInfo`.
160
276
 
161
277
  ```typescript
162
- // Start the HTTP + WebSocket server
163
- start(): Promise<void>;
278
+ import { toWorkerInfo, type ConnectedWorker } from "@hardlydifficult/worker-server";
164
279
 
165
- // Stop the server and close all connections
166
- stop(): Promise<void>;
280
+ const internal: ConnectedWorker = /* ... */;
281
+ const publicInfo = toWorkerInfo(internal);
282
+ // No websocket reference in publicInfo
167
283
  ```
168
284
 
169
- ### WorkerInfo
285
+ ### Types
286
+
287
+ | Type | Description |
288
+ |------|-------------|
289
+ | `WorkerStatus` | `available`, `busy`, `draining`, `unhealthy` |
290
+ | `ModelInfo` | Model capabilities and metadata |
291
+ | `WorkerCapabilities` | Worker capacity, models, and concurrency limits |
292
+ | `WorkerInfo` | Public worker state |
293
+ | `ConnectedWorker` | Internal state (includes WebSocket) |
294
+ | `WorkerServerOptions` | Configuration for `WorkerServer` |
295
+ | `WorkerServerLogger` | Logger interface |
296
+ | `HttpRequestHandler` | HTTP request handler type |
297
+ | `WorkerMessageHandler<T>` | Typed message handler |
298
+ | `WorkerConnectedHandler` | Worker connected event handler |
299
+ | `WorkerDisconnectedHandler` | Worker disconnected event handler |
300
+ | `WebSocketConnectionHandler` | Custom WebSocket endpoint handler |
301
+
302
+ ### Constants and Defaults
170
303
 
171
- Public interface representing a connected worker:
304
+ Default timeouts (milliseconds):
172
305
 
173
306
  ```typescript
174
- interface WorkerInfo {
175
- readonly id: string;
176
- readonly name: string;
177
- readonly status: WorkerStatus; // "available" | "busy" | "draining" | "unhealthy"
178
- readonly capabilities: WorkerCapabilities;
179
- readonly sessionId: string;
180
- readonly connectedAt: Date;
181
- readonly lastHeartbeat: Date;
182
- readonly activeRequests: number;
183
- readonly completedRequests: number;
184
- readonly pendingRequestIds: ReadonlySet<string>;
185
- readonly categoryActiveRequests: ReadonlyMap<string, number>;
307
+ {
308
+ heartbeatTimeoutMs: 60_000,
309
+ healthCheckIntervalMs: 10_000,
310
+ heartbeatIntervalMs: 15_000,
186
311
  }
187
312
  ```
188
313
 
189
- ### WorkerCapabilities
314
+ ### Utility Functions
190
315
 
191
- Describes a worker's capabilities:
316
+ #### `safeCompare(a, b)`
317
+
318
+ Timing-safe string comparison.
192
319
 
193
320
  ```typescript
194
- interface WorkerCapabilities {
195
- models: ModelInfo[];
196
- maxConcurrentRequests: number;
197
- metadata?: Record<string, unknown>;
198
- concurrencyLimits?: Record<string, number>;
321
+ import { safeCompare } from "@hardlydifficult/worker-server";
322
+
323
+ const isValid = safeCompare(inputToken, secretToken);
324
+ ```
325
+
326
+ ## Appendix
327
+
328
+ ### Worker Registration Protocol
329
+
330
+ Workers send:
331
+
332
+ ```json
333
+ {
334
+ "type": "worker_registration",
335
+ "workerId": "worker-1",
336
+ "workerName": "My Worker",
337
+ "capabilities": {
338
+ "models": [{
339
+ "modelId": "gpt-4",
340
+ "displayName": "GPT-4",
341
+ "maxContextTokens": 8192,
342
+ "maxOutputTokens": 4096,
343
+ "supportsStreaming": true
344
+ }],
345
+ "maxConcurrentRequests": 5,
346
+ "concurrencyLimits": {
347
+ "inference": 2,
348
+ "embedding": 4
349
+ }
350
+ },
351
+ "authToken": "optional"
199
352
  }
200
353
  ```
201
354
 
202
- ### ModelInfo
355
+ Server responds:
203
356
 
204
- Describes a supported model:
357
+ ```json
358
+ {
359
+ "type": "worker_registration_ack",
360
+ "success": true,
361
+ "sessionId": "uuid",
362
+ "heartbeatIntervalMs": 15000
363
+ }
364
+ ```
205
365
 
206
- ```typescript
207
- interface ModelInfo {
208
- modelId: string;
209
- displayName: string;
210
- maxContextTokens: number;
211
- maxOutputTokens: number;
212
- supportsStreaming: boolean;
213
- supportsVision?: boolean;
214
- supportsTools?: boolean;
366
+ ### Worker Heartbeat Protocol
367
+
368
+ Workers send:
369
+
370
+ ```json
371
+ {
372
+ "type": "heartbeat",
373
+ "workerId": "worker-1",
374
+ "timestamp": "2024-01-01T00:00:00.000Z"
215
375
  }
216
376
  ```
217
377
 
218
- ### WorkerStatus
378
+ Server responds:
219
379
 
220
- Worker state enumeration:
380
+ ```json
381
+ {
382
+ "type": "heartbeat_ack",
383
+ "timestamp": "2024-01-01T00:00:00.000Z",
384
+ "nextHeartbeatDeadline": "2024-01-01T00:01:00.000Z"
385
+ }
386
+ ```
221
387
 
222
- | Status | Description |
223
- |--------|-------------|
224
- | `available` | Worker is idle and can accept new requests |
225
- | `busy` | Worker is at max concurrent requests but may accept model-agnostic tasks |
226
- | `draining` | Worker is being gracefully decommissioned |
227
- | `unhealthy` | Worker has missed heartbeats and is presumed degraded |
388
+ ### Status Transitions
228
389
 
229
- ## Worker Protocol
390
+ - `Available` → `Busy` when `activeRequests >= maxConcurrentRequests`
391
+ - `Busy` → `Available` when `activeRequests < maxConcurrentRequests`
392
+ - Any → `Unhealthy` on heartbeat timeout
393
+ - `Unhealthy` → `Available/Busy` on heartbeat recovery
394
+
395
+ ### Concurrent Request Tracking
396
+
397
+ The pool tracks requests per-worker and per-category (if provided). It automatically decrements the category count when releasing a tracked request.
398
+
399
+ ### Worker Protocol Summary
230
400
 
231
401
  Workers communicate using JSON messages with a `type` field:
232
402
 
@@ -237,10 +407,4 @@ Workers communicate using JSON messages with a `type` field:
237
407
  | `heartbeat` | Worker → Server | Periodic health check |
238
408
  | `heartbeat_ack` | Server → Worker | Acknowledgment with next deadline |
239
409
 
240
- All other message types are routed to registered handlers via `onWorkerMessage()`.
241
-
242
- ## Health Monitoring
243
-
244
- - Workers missing heartbeats for `heartbeatTimeoutMs` are marked `unhealthy`
245
- - Workers missing heartbeats for `3 × heartbeatTimeoutMs` are disconnected
246
- - Health checks run every `healthCheckIntervalMs`
410
+ All other message types are routed to registered handlers via `onWorkerMessage()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/worker-server",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [