@soederpop/luca 0.0.26 → 0.0.28

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.
@@ -1,5 +1,5 @@
1
1
  // Auto-generated scaffold and MCP readme content
2
- // Generated at: 2026-03-22T20:57:24.026Z
2
+ // Generated at: 2026-03-23T07:45:57.906Z
3
3
  // Source: docs/scaffolds/*.md, docs/examples/assistant/, and docs/mcp/readme.md
4
4
  //
5
5
  // Do not edit manually. Run: luca build-scaffolds
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
  import { ServerStateSchema, ServerOptionsSchema, ServerEventsSchema } from '../schemas/base.js'
3
3
  import { type StartOptions, Server, type ServerState } from '../server.js';
4
4
  import { WebSocketServer as BaseServer } from 'ws'
5
+ import type { PendingRequest } from '../clients/websocket.js'
5
6
 
6
7
  declare module '../server' {
7
8
  interface AvailableServers {
@@ -29,6 +30,12 @@ export const SocketServerEventsSchema = ServerEventsSchema.extend({
29
30
  * When `json` mode is disabled, raw message data is emitted as-is and
30
31
  * `send()` / `broadcast()` still JSON-stringify for safety.
31
32
  *
33
+ * Supports ask/reply semantics when paired with the Luca WebSocket client.
34
+ * The server can `ask(ws, type, data)` a connected client and await a typed
35
+ * response, or handle incoming asks from clients by listening for messages
36
+ * with a `requestId` and replying via `send(ws, { replyTo, data })`.
37
+ * Requests time out if no reply arrives within the configurable window.
38
+ *
32
39
  * @extends Server
33
40
  *
34
41
  * @example
@@ -40,6 +47,12 @@ export const SocketServerEventsSchema = ServerEventsSchema.extend({
40
47
  * console.log('Received:', data)
41
48
  * ws.broadcast({ echo: data })
42
49
  * })
50
+ *
51
+ * // ask/reply: request info from a connected client
52
+ * ws.on('connection', async (client) => {
53
+ * const info = await ws.ask(client, 'identify')
54
+ * console.log('Client says:', info)
55
+ * })
43
56
  * ```
44
57
  */
45
58
  export class WebsocketServer<T extends ServerState = ServerState, K extends SocketServerOptions = SocketServerOptions> extends Server<T,K> {
@@ -63,6 +76,7 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
63
76
  }
64
77
 
65
78
  connections : Set<any> = new Set()
79
+ _pending = new Map<string, PendingRequest>()
66
80
 
67
81
  async broadcast(message: any) {
68
82
  for(const ws of this.connections) {
@@ -77,6 +91,66 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
77
91
  return this
78
92
  }
79
93
 
94
+ /**
95
+ * Send a request to a specific client and wait for a correlated response.
96
+ * The client is expected to reply with a message whose `replyTo` matches
97
+ * the `requestId` of this message.
98
+ *
99
+ * @param ws - The WebSocket client to ask
100
+ * @param type - A string identifying the request type
101
+ * @param data - Optional payload
102
+ * @param timeout - How long to wait (default 10 000 ms)
103
+ * @returns The `data` field of the response
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * ws.on('connection', async (client) => {
108
+ * const info = await ws.ask(client, 'identify')
109
+ * console.log('Client says:', info)
110
+ * })
111
+ * ```
112
+ */
113
+ async ask<R = any>(ws: any, type: string, data?: any, timeout = 10000): Promise<R> {
114
+ const requestId = this.container.utils.uuid()
115
+
116
+ return new Promise<R>((resolve, reject) => {
117
+ const timer = setTimeout(() => {
118
+ this._pending.delete(requestId)
119
+ reject(new Error(`ask("${type}") timed out after ${timeout}ms`))
120
+ }, timeout)
121
+
122
+ this._pending.set(requestId, { resolve, reject, timer })
123
+ this.send(ws, { type, data, requestId })
124
+ })
125
+ }
126
+
127
+ /** @internal Resolve a pending ask() if the incoming message has a replyTo field. Returns true if handled. */
128
+ _handleReply(message: any): boolean {
129
+ if (!message || !message.replyTo) return false
130
+
131
+ const pending = this._pending.get(message.replyTo)
132
+ if (!pending) return false
133
+
134
+ this._pending.delete(message.replyTo)
135
+ clearTimeout(pending.timer)
136
+
137
+ if (message.error) {
138
+ pending.reject(new Error(message.error))
139
+ } else {
140
+ pending.resolve(message.data)
141
+ }
142
+ return true
143
+ }
144
+
145
+ /** @internal Reject all pending ask() calls — used on stop. */
146
+ _rejectAllPending(reason: string) {
147
+ for (const [id, pending] of this._pending) {
148
+ clearTimeout(pending.timer)
149
+ pending.reject(new Error(reason))
150
+ }
151
+ this._pending.clear()
152
+ }
153
+
80
154
  /**
81
155
  * Start the WebSocket server. A runtime `port` overrides the constructor
82
156
  * option and is written to state before the underlying `ws.Server` is created,
@@ -115,6 +189,17 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
115
189
  data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
116
190
  } catch {}
117
191
  }
192
+
193
+ // Route reply messages to pending ask() calls
194
+ if (this._handleReply(data)) return
195
+
196
+ // If this message is a request (has requestId), provide a reply helper
197
+ if (data && data.requestId) {
198
+ const requestId = data.requestId
199
+ data.reply = (responseData: any) => this.send(ws, { replyTo: requestId, data: responseData })
200
+ data.replyError = (error: string) => this.send(ws, { replyTo: requestId, error })
201
+ }
202
+
118
203
  this.emit('message', data, ws)
119
204
  })
120
205
  })
@@ -129,6 +214,8 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
129
214
  return this
130
215
  }
131
216
 
217
+ this._rejectAllPending('WebSocket server stopped')
218
+
132
219
  await Promise.race([
133
220
  new Promise<void>((resolve) => {
134
221
  if (!this._wss) {
@@ -6,7 +6,10 @@ import { WebSocketClientEventsSchema } from '../../schemas/base.js'
6
6
  /**
7
7
  * Web-specific WebSocket client implementation using isomorphic-ws.
8
8
  * Extends the base WebSocketClient with platform-specific transport and
9
- * an envelope format that wraps sent data with a unique ID.
9
+ * an envelope format that wraps sent data with a unique ID. Inherits
10
+ * ask/reply semantics from the base WebSocketClient — protocol messages
11
+ * (those with `requestId` or `replyTo`) bypass the envelope wrapper to
12
+ * maintain compatibility with the Luca WebSocket server's ask/reply flow.
10
13
  */
11
14
  export class SocketClient<T extends WebSocketClientState = WebSocketClientState, K extends WebSocketClientOptions = WebSocketClientOptions> extends WebSocketClient<T,K> {
12
15
  // @ts-expect-error widening ws type for isomorphic-ws compatibility
@@ -20,6 +23,8 @@ export class SocketClient<T extends WebSocketClientState = WebSocketClientState,
20
23
  /**
21
24
  * Send data over the WebSocket with an ID envelope.
22
25
  * Wraps the payload in { id, data } before JSON serialization.
26
+ * Messages with a `requestId` or `replyTo` are sent as-is to
27
+ * preserve the ask/reply protocol.
23
28
  */
24
29
  override async send(data: any) {
25
30
  if(!this.isConnected && !this.hasError) {
@@ -30,10 +35,15 @@ export class SocketClient<T extends WebSocketClientState = WebSocketClientState,
30
35
  throw new Error(`Missing websocket instance`)
31
36
  }
32
37
 
33
- this.ws.send(JSON.stringify({
34
- id: this.container.utils.uuid(),
35
- data
36
- }))
38
+ // Protocol messages (ask/reply) bypass the envelope
39
+ if (data && (data.requestId || data.replyTo)) {
40
+ this.ws.send(JSON.stringify(data))
41
+ } else {
42
+ this.ws.send(JSON.stringify({
43
+ id: this.container.utils.uuid(),
44
+ data
45
+ }))
46
+ }
37
47
  }
38
48
 
39
49
  /**
@@ -64,7 +74,13 @@ export class SocketClient<T extends WebSocketClientState = WebSocketClientState,
64
74
  }
65
75
 
66
76
  ws.onmessage = (event: any) => {
67
- this.emit('message', event)
77
+ let data = event?.data ?? event
78
+ try {
79
+ data = JSON.parse(typeof data === 'string' ? data : data.toString())
80
+ } catch {}
81
+ if (!this._handleReply(data)) {
82
+ this.emit('message', data)
83
+ }
68
84
  }
69
85
 
70
86
  ws.onclose = (event: any) => {
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, afterAll } from 'bun:test'
2
+ import { NodeContainer } from '../src/node/container'
3
+
4
+ describe('WebSocket ask/reply protocol', () => {
5
+ const container = new NodeContainer()
6
+ const server = container.server('websocket', { json: true })
7
+ let clientWsOnServer: any
8
+
9
+ afterAll(async () => {
10
+ try { server.connections.forEach((ws: any) => ws.close?.()) } catch {}
11
+ try { await server.stop() } catch {}
12
+ })
13
+
14
+ it('setup: start server and connect client', async () => {
15
+ const connPromise = new Promise<any>((resolve) => {
16
+ server.on('connection', resolve)
17
+ })
18
+
19
+ server.on('message', (data: any) => {
20
+ if (data.type === 'greet') {
21
+ data.reply({ greeting: `hello ${data.data.name}` })
22
+ }
23
+ if (data.type === 'fail') {
24
+ data.replyError('something went wrong')
25
+ }
26
+ })
27
+
28
+ await server.start({ port: 19880 })
29
+
30
+ // Use bun's native WebSocket as the client
31
+ const ws = new WebSocket('ws://localhost:19880')
32
+ await new Promise<void>((resolve, reject) => {
33
+ ws.onopen = () => resolve()
34
+ ws.onerror = (e) => reject(e)
35
+ })
36
+
37
+ clientWsOnServer = await connPromise
38
+ expect(clientWsOnServer).toBeDefined()
39
+
40
+ // Wire up the client to handle server-initiated asks
41
+ ws.onmessage = (event: any) => {
42
+ const msg = JSON.parse(event.data)
43
+ if (msg.requestId && msg.type === 'identify') {
44
+ ws.send(JSON.stringify({ replyTo: msg.requestId, data: { role: 'worker' } }))
45
+ }
46
+ }
47
+
48
+ // Stash ws for other tests
49
+ ;(globalThis as any).__testWs = ws
50
+ })
51
+
52
+ it('client.ask() sends a request and resolves with the server reply', async () => {
53
+ const ws = (globalThis as any).__testWs as WebSocket
54
+
55
+ // Manually implement the client-side ask protocol
56
+ const requestId = container.utils.uuid()
57
+ const result = await new Promise<any>((resolve, reject) => {
58
+ const timer = setTimeout(() => reject(new Error('timeout')), 5000)
59
+ const handler = (event: any) => {
60
+ const msg = JSON.parse(event.data)
61
+ if (msg.replyTo === requestId) {
62
+ clearTimeout(timer)
63
+ ws.removeEventListener('message', handler)
64
+ if (msg.error) reject(new Error(msg.error))
65
+ else resolve(msg.data)
66
+ }
67
+ }
68
+ ws.addEventListener('message', handler)
69
+ ws.send(JSON.stringify({ type: 'greet', data: { name: 'luca' }, requestId }))
70
+ })
71
+
72
+ expect(result).toEqual({ greeting: 'hello luca' })
73
+ })
74
+
75
+ it('server.ask() sends a request and resolves with the client reply', async () => {
76
+ const result = await server.ask(clientWsOnServer, 'identify')
77
+ expect(result).toEqual({ role: 'worker' })
78
+ })
79
+
80
+ it('ask() rejects with error when reply contains error', async () => {
81
+ const ws = (globalThis as any).__testWs as WebSocket
82
+
83
+ const requestId = container.utils.uuid()
84
+ const promise = new Promise<any>((resolve, reject) => {
85
+ const timer = setTimeout(() => reject(new Error('timeout')), 5000)
86
+ const handler = (event: any) => {
87
+ const msg = JSON.parse(event.data)
88
+ if (msg.replyTo === requestId) {
89
+ clearTimeout(timer)
90
+ ws.removeEventListener('message', handler)
91
+ if (msg.error) reject(new Error(msg.error))
92
+ else resolve(msg.data)
93
+ }
94
+ }
95
+ ws.addEventListener('message', handler)
96
+ ws.send(JSON.stringify({ type: 'fail', requestId }))
97
+ })
98
+
99
+ await expect(promise).rejects.toThrow('something went wrong')
100
+ })
101
+ })