@soederpop/luca 0.0.25 → 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-22T06:53:18.105Z
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
package/src/server.ts CHANGED
@@ -79,6 +79,44 @@ export class Server<T extends ServerState = ServerState, K extends ServerOptions
79
79
  return super.container as NodeContainer
80
80
  }
81
81
 
82
+ /** Async functions passed to `.use()` before `start()` — drained in `start()`. */
83
+ _pendingPlugins: Promise<void>[] = []
84
+
85
+ /**
86
+ * Register a setup function against this server. The function receives the
87
+ * server instance and can configure it however it likes.
88
+ *
89
+ * - If the server hasn't started yet, async return values are queued and
90
+ * awaited when `start()` is called.
91
+ * - If the server is already listening, the function is fire-and-forget.
92
+ * - Always returns `this` synchronously for chaining.
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * server
97
+ * .use((s) => s.app.use(cors()))
98
+ * .use(async (s) => { await loadRoutes(s) })
99
+ * await server.start()
100
+ * ```
101
+ */
102
+ use(fn: (server: this) => void | Promise<void>): this {
103
+ const result = fn(this)
104
+ if (result && typeof (result as any).then === 'function') {
105
+ if (!this.isListening) {
106
+ this._pendingPlugins.push(result as Promise<void>)
107
+ }
108
+ }
109
+ return this
110
+ }
111
+
112
+ /** Drain all pending `.use()` promises. Subclasses should call this at the top of `start()`. */
113
+ protected async _drainPendingPlugins() {
114
+ if (this._pendingPlugins.length) {
115
+ await Promise.all(this._pendingPlugins)
116
+ this._pendingPlugins = []
117
+ }
118
+ }
119
+
82
120
  get isListening() {
83
121
  return !!this.state.get('listening')
84
122
  }
@@ -117,6 +155,8 @@ export class Server<T extends ServerState = ServerState, K extends ServerOptions
117
155
  return this
118
156
  }
119
157
 
158
+ await this._drainPendingPlugins()
159
+
120
160
  if (options?.port) {
121
161
  this.state.set('port', options.port)
122
162
  }
@@ -104,6 +104,8 @@ export class ExpressServer<T extends ServerState = ServerState, K extends Expres
104
104
  return this
105
105
  }
106
106
 
107
+ await this._drainPendingPlugins()
108
+
107
109
  // Apply runtime port to state so this.port reflects the override
108
110
  if (options?.port) {
109
111
  this.state.set('port', options.port)
@@ -472,6 +472,7 @@ export class MCPServer extends Server<MCPServerState, MCPServerOptions> {
472
472
  stdioCompat?: StdioCompatMode
473
473
  }) {
474
474
  if (this.isListening) return this
475
+ await this._drainPendingPlugins()
475
476
  if (!this.isConfigured) await this.configure()
476
477
 
477
478
  const transport = options?.transport || this.options.transport || 'stdio'
@@ -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,
@@ -89,6 +163,8 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
89
163
  return this
90
164
  }
91
165
 
166
+ await this._drainPendingPlugins()
167
+
92
168
  // Apply runtime port to state before configure/wss touches it
93
169
  if (options?.port) {
94
170
  this.state.set('port', options.port)
@@ -113,6 +189,17 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
113
189
  data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
114
190
  } catch {}
115
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
+
116
203
  this.emit('message', data, ws)
117
204
  })
118
205
  })
@@ -127,6 +214,8 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
127
214
  return this
128
215
  }
129
216
 
217
+ this._rejectAllPending('WebSocket server stopped')
218
+
130
219
  await Promise.race([
131
220
  new Promise<void>((resolve) => {
132
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
+ })