@polytric/openws-sdkgen 0.0.12 → 0.0.14

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.
@@ -4,7 +4,7 @@
4
4
  <% if (ctx.isTypeScript) { -%>
5
5
  import * as WS from '@polytric/openws/class'
6
6
  import * as Fluent from '@polytric/openws/fluent'
7
- import type { ApiProto } from '@polytric/openws/fluent'
7
+ import type { PeerProto } from '@polytric/openws/fluent'
8
8
  import type { BindTransportOptions, OpenWsEndpoint, OpenWsEnvelope, Transport, Unsubscribe } from '<%= networkImport %>'
9
9
  import { WsTransport, bindTransport, canBindTransport, decodeEnvelope, encodeEnvelope } from '<%= networkImport %>'
10
10
  <% if (ctx.handlers.length > 0) { -%>
@@ -19,34 +19,70 @@ import {
19
19
  <%= ctx.hostRoleClassName %>,
20
20
  <% for (const remoteRole of ctx.remoteRoles) { -%>
21
21
  <%= remoteRole.roleClassName %>,
22
- type <%= remoteRole.apiName %>,
22
+ type <%= remoteRole.peerName %>,
23
23
  <% } -%>
24
24
  } from '<%= rolesImport %>'
25
25
 
26
26
  <% for (const remoteRole of ctx.remoteRoles) { -%>
27
- export type <%= remoteRole.scopedApiName %> = Pick<<%= remoteRole.apiName %>, <%- remoteRole.allowedMethodNames.length > 0 ? remoteRole.allowedMethodNames.map(methodName => JSON.stringify(methodName)).join(' | ') : 'never' %>>
27
+ /**
28
+ * Connected <%- JSON.stringify(remoteRole.roleName) %> peer handle as seen by <%= ctx.className %>.
29
+ */
30
+ export type <%= remoteRole.scopedPeerName %> = Pick<<%= remoteRole.peerName %>, <%- remoteRole.allowedMethodNames.length > 0 ? remoteRole.allowedMethodNames.map(methodName => JSON.stringify(methodName)).join(' | ') : 'never' %>>
28
31
  <% } -%>
29
32
 
30
33
  <% if (ctx.remoteRoles.length > 0) { -%>
31
- export type <%= ctx.className %>PeerApi = <%= ctx.remoteRoles.map(remoteRole => remoteRole.scopedApiName).join(' | ') %>
34
+ /**
35
+ * Union of peer handles this generated client can connect to.
36
+ */
37
+ export type <%= ctx.className %>Peer = <%= ctx.remoteRoles.map(remoteRole => remoteRole.scopedPeerName).join(' | ') %>
32
38
 
33
39
  <% } else { -%>
34
- export type <%= ctx.className %>PeerApi = unknown
40
+ /**
41
+ * Union of peer handles this generated client can connect to.
42
+ */
43
+ export type <%= ctx.className %>Peer = unknown
35
44
 
36
45
  <% } -%>
37
46
  <% if (ctx.handlers.length > 0) { -%>
38
- export type <%= ctx.className %>MessageHandler<TPayload, TApi = <%= ctx.className %>PeerApi> = (payload: TPayload, api: TApi) => void | Promise<void>
47
+ /**
48
+ * Application callback for a received OpenWS message.
49
+ */
50
+ export type <%= ctx.className %>MessageHandler<TPayload, TPeer = <%= ctx.className %>Peer> = (payload: TPayload, peer: TPeer) => void | Promise<void>
39
51
 
40
52
  <% } -%>
53
+ /**
54
+ * Application callback for message or socket errors.
55
+ */
41
56
  export type <%= ctx.className %>ErrorHandler = (error: unknown) => void | Promise<void>
57
+ /**
58
+ * Application callback for connection lifecycle events.
59
+ */
60
+ export type <%= ctx.className %>LifecycleHandler = (roleName: string) => void | Promise<void>
61
+ /**
62
+ * Application callback for connection lifecycle errors.
63
+ */
64
+ export type <%= ctx.className %>LifecycleErrorHandler = (roleName: string, error: Error) => void | Promise<void>
65
+
66
+ type <%= ctx.className %>Connection = {
67
+ roleName: string
68
+ session: Fluent.Session
69
+ peer: PeerProto
70
+ }
42
71
 
72
+ /**
73
+ * Generated OpenWS client for the <%- JSON.stringify(ctx.roleName) %> role in the <%- JSON.stringify(ctx.networkName) %> network.
74
+ *
75
+ * Application code calls command methods such as `connect` and registers
76
+ * callbacks with `on...` methods. Transport and framework glue call `handle...`
77
+ * methods to deliver inbound data, errors, and lifecycle events.
78
+ */
43
79
  export class <%= ctx.className %> {
44
80
  static readonly CONFIG = <%= ctx.hostRoleClassName %>.CONFIG
45
81
 
46
82
  readonly name = <%= ctx.className %>.CONFIG.name
47
83
  readonly description = <%= ctx.className %>.CONFIG.description
48
84
  <% for (const remoteRole of ctx.remoteRoles) { -%>
49
- <%= remoteRole.apiVarName %>!: <%= remoteRole.scopedApiName %>
85
+ <%= remoteRole.peerVarName %>!: <%= remoteRole.scopedPeerName %>
50
86
  <% } -%>
51
87
 
52
88
  private readonly binder: Fluent.NetworkBinder
@@ -54,14 +90,18 @@ export class <%= ctx.className %> {
54
90
  private readonly fromRole = <%- JSON.stringify(ctx.roleName) %>
55
91
  private readonly sendEnvelope: (toRole: string, messageName: string, payload: unknown) => Promise<void>
56
92
  private transportUnsubscribe?: Unsubscribe
57
- private readonly apisByRole: Record<string, ApiProto> = {}
58
- private readonly apisByMessageName: Record<string, ApiProto> = {}
59
- private readonly handlersByMessageName: Record<string, (payload: unknown, api: <%= ctx.className %>PeerApi) => Promise<void>> = {}
93
+ private readonly connections = new Set<<%= ctx.className %>Connection>()
94
+ private readonly peersByMessageName: Record<string, PeerProto> = {}
95
+ private readonly peerRoleByMessageName: Record<string, string> = {}
96
+ private readonly handlersByMessageName: Record<string, (payload: unknown, peer: <%= ctx.className %>Peer) => Promise<void>> = {}
60
97
  private readonly messageErrorHandlers = new Set<<%= ctx.className %>ErrorHandler>()
61
98
  private readonly socketErrorHandlers = new Set<<%= ctx.className %>ErrorHandler>()
99
+ private readonly openHandlers = new Set<<%= ctx.className %>LifecycleHandler>()
100
+ private readonly closeHandlers = new Set<<%= ctx.className %>LifecycleHandler>()
101
+ private readonly lifecycleErrorHandlers = new Set<<%= ctx.className %>LifecycleErrorHandler>()
62
102
  <% for (const handler of ctx.handlers) { -%>
63
- <% const handlerApiType = handler.bindFromRoles.map(fromRole => ctx.remoteRoles.find(remoteRole => remoteRole.roleName === fromRole.roleName)?.scopedApiName).filter(Boolean).join(' | ') || `${ctx.className}PeerApi` -%>
64
- private readonly <%= handler.listenerFieldName %> = new Set<<%= ctx.className %>MessageHandler<<%= handler.payloadType %>, <%= handlerApiType %>>>()
103
+ <% const handlerPeerType = handler.bindFromRoles.map(fromRole => ctx.remoteRoles.find(remoteRole => remoteRole.roleName === fromRole.roleName)?.scopedPeerName).filter(Boolean).join(' | ') || `${ctx.className}Peer` -%>
104
+ private readonly <%= handler.listenerFieldName %> = new Set<<%= ctx.className %>MessageHandler<<%= handler.payloadType %>, <%= handlerPeerType %>>>()
65
105
  <% } -%>
66
106
 
67
107
  constructor(
@@ -81,15 +121,26 @@ export class <%= ctx.className %> {
81
121
  await this.transport.send(encodeEnvelope({ fromRole: this.fromRole, messageName, payload }))
82
122
  }
83
123
  <% for (const handler of ctx.handlers) { -%>
84
- <% const handlerApiType = handler.bindFromRoles.map(fromRole => ctx.remoteRoles.find(remoteRole => remoteRole.roleName === fromRole.roleName)?.scopedApiName).filter(Boolean).join(' | ') || `${ctx.className}PeerApi` -%>
85
- this.handlersByMessageName[<%- JSON.stringify(handler.messageName) %>] = async (payload, api) => {
86
- await this.<%= handler.dispatchMethodName %>(payload as <%= handler.payloadType %>, api as <%= handlerApiType %>)
124
+ <% const handlerPeerType = handler.bindFromRoles.map(fromRole => ctx.remoteRoles.find(remoteRole => remoteRole.roleName === fromRole.roleName)?.scopedPeerName).filter(Boolean).join(' | ') || `${ctx.className}Peer` -%>
125
+ this.handlersByMessageName[<%- JSON.stringify(handler.messageName) %>] = async (payload, peer) => {
126
+ await this.<%= handler.dispatchMethodName %>(payload as <%= handler.payloadType %>, peer as <%= handlerPeerType %>)
87
127
  }
88
128
  <% for (const fromRole of handler.bindFromRoles) { -%>
89
- this.binder.fromRoles[<%- JSON.stringify(fromRole.roleName) %>].on(<%- JSON.stringify(handler.messageName) %>, async (payload, api) => {
90
- await this.<%= handler.dispatchMethodName %>(payload as <%= handler.payloadType %>, api as unknown as <%= handlerApiType %>)
129
+ this.binder.fromRoles[<%- JSON.stringify(fromRole.roleName) %>].on(<%- JSON.stringify(handler.messageName) %>, async (payload, peer) => {
130
+ await this.<%= handler.dispatchMethodName %>(payload as <%= handler.payloadType %>, peer as unknown as <%= handlerPeerType %>)
91
131
  })
92
132
  <% } -%>
133
+ <% } -%>
134
+ <% for (const remoteRole of ctx.remoteRoles) { -%>
135
+ this.binder.fromRoles[<%- JSON.stringify(remoteRole.roleName) %>].onOpen(async fromRole => {
136
+ await this.handleOpen(fromRole)
137
+ })
138
+ this.binder.fromRoles[<%- JSON.stringify(remoteRole.roleName) %>].onClose(async fromRole => {
139
+ await this.handleClose(fromRole)
140
+ })
141
+ this.binder.fromRoles[<%- JSON.stringify(remoteRole.roleName) %>].onError(async (fromRole, error) => {
142
+ await this.handleError(fromRole, error)
143
+ })
93
144
  <% } -%>
94
145
  if (canBindTransport(transport)) {
95
146
  this.bindTransport(transport)
@@ -97,26 +148,33 @@ export class <%= ctx.className %> {
97
148
  }
98
149
 
99
150
  <% for (const remoteRole of ctx.remoteRoles) { -%>
100
- async connect(roleName: <%- JSON.stringify(remoteRole.roleName) %>, endpoint?: OpenWsEndpoint): Promise<<%= remoteRole.scopedApiName %>>
151
+ /**
152
+ * Connects this client to the <%- JSON.stringify(remoteRole.roleName) %> role and returns the connected peer handle.
153
+ */
154
+ async connect(roleName: <%- JSON.stringify(remoteRole.roleName) %>, endpoint?: OpenWsEndpoint): Promise<<%= remoteRole.scopedPeerName %>>
101
155
  <% } -%>
102
- async connect(roleName: string, endpoint?: OpenWsEndpoint): Promise<<%= ctx.className %>PeerApi> {
156
+ async connect(roleName: string, endpoint?: OpenWsEndpoint): Promise<<%= ctx.className %>Peer> {
103
157
  switch (roleName) {
104
158
  <% for (const remoteRole of ctx.remoteRoles) { -%>
105
159
  case <%- JSON.stringify(remoteRole.roleName) %>: {
106
160
  const remoteEndpoint = endpoint ?? (<%- remoteRole.endpoints.length > 0 ? JSON.stringify(remoteRole.endpoints[0]) : 'undefined' %> as OpenWsEndpoint | undefined)
107
161
  await this.transport.connect?.(roleName, remoteEndpoint)
108
- if (!this.apisByRole[<%- JSON.stringify(remoteRole.roleName) %>]) {
109
- const <%= remoteRole.varName %>Api = this.runtime.createApi(<%- JSON.stringify(remoteRole.roleName) %>, this.sendEnvelope)
110
- this.<%= remoteRole.apiVarName %> = <%= remoteRole.varName %>Api as unknown as <%= remoteRole.scopedApiName %>
111
- this.apisByRole[<%- JSON.stringify(remoteRole.roleName) %>] = <%= remoteRole.varName %>Api
162
+ const session = this.runtime.newSession(this.sendEnvelope)
163
+ const <%= remoteRole.varName %>Peer = await session.open(<%- JSON.stringify(remoteRole.roleName) %>)
164
+ this.connections.add({
165
+ roleName: <%- JSON.stringify(remoteRole.roleName) %>,
166
+ session,
167
+ peer: <%= remoteRole.varName %>Peer,
168
+ })
169
+ this.<%= remoteRole.peerVarName %> = <%= remoteRole.varName %>Peer as unknown as <%= remoteRole.scopedPeerName %>
112
170
  <% for (const handler of ctx.handlers) { -%>
113
- <% const handlerDefaultApi = handler.bindFromRoles.find(fromRole => ctx.remoteRoles.some(remoteRole => remoteRole.roleName === fromRole.roleName)) ?? ctx.remoteRoles[0] -%>
114
- <% if (handlerDefaultApi?.roleName === remoteRole.roleName) { -%>
115
- this.apisByMessageName[<%- JSON.stringify(handler.messageName) %>] = <%= remoteRole.varName %>Api
171
+ <% const handlerDefaultPeer = handler.bindFromRoles.find(fromRole => ctx.remoteRoles.some(remoteRole => remoteRole.roleName === fromRole.roleName)) ?? ctx.remoteRoles[0] -%>
172
+ <% if (handlerDefaultPeer?.roleName === remoteRole.roleName) { -%>
173
+ this.peersByMessageName[<%- JSON.stringify(handler.messageName) %>] = <%= remoteRole.varName %>Peer
174
+ this.peerRoleByMessageName[<%- JSON.stringify(handler.messageName) %>] = <%- JSON.stringify(remoteRole.roleName) %>
116
175
  <% } -%>
117
176
  <% } -%>
118
- }
119
- return this.<%= remoteRole.apiVarName %>
177
+ return this.<%= remoteRole.peerVarName %>
120
178
  }
121
179
  <% } -%>
122
180
  default:
@@ -124,28 +182,61 @@ export class <%= ctx.className %> {
124
182
  }
125
183
  }
126
184
 
127
- async disconnect(roleName: string): Promise<void> {
128
- await this.transport.disconnect?.(roleName)
129
- delete this.apisByRole[roleName]
185
+ <% for (const remoteRole of ctx.remoteRoles) { -%>
186
+ async disconnect(peer: <%= remoteRole.scopedPeerName %>): Promise<void>
187
+ <% } -%>
188
+ async disconnect(peer: string): Promise<void>
189
+ /**
190
+ * Disconnects from a peer and closes its session.
191
+ *
192
+ * Pass the peer returned by `connect` to close that exact peer connection.
193
+ * Passing a role name closes all active peer connections for that role.
194
+ */
195
+ async disconnect(peer: string | <%= ctx.className %>Peer): Promise<void> {
196
+ if (typeof peer === 'string') {
197
+ await this.transport.disconnect?.(peer)
198
+ await this.closeSessions(peer)
199
+ return
200
+ }
201
+ const connection = this.findConnectionByPeer(peer as PeerProto)
202
+ if (!connection) {
203
+ throw new Error('Peer is not connected')
204
+ }
205
+ await this.closeConnection(connection)
130
206
  }
131
207
 
208
+ /**
209
+ * Binds a transport to this client.
210
+ *
211
+ * Transport events call this client's `handle...` methods. Application code
212
+ * should use `on...` methods to observe those events.
213
+ */
132
214
  bindTransport(transport: Transport = this.transport, options: BindTransportOptions = {}): Unsubscribe {
133
215
  this.transportUnsubscribe?.()
134
216
  this.transportUnsubscribe = bindTransport(transport, this, options)
135
217
  return this.transportUnsubscribe
136
218
  }
137
219
 
220
+ /**
221
+ * Removes the current transport event bindings.
222
+ */
138
223
  unbindTransport(): void {
139
224
  this.transportUnsubscribe?.()
140
225
  this.transportUnsubscribe = undefined
141
226
  }
142
227
 
143
- async messageError(error: unknown): Promise<void> {
228
+ /**
229
+ * Framework entrypoint for message decoding or dispatch failures.
230
+ */
231
+ async handleMessageError(error: unknown): Promise<void> {
144
232
  for (const handler of this.messageErrorHandlers) {
145
233
  await handler(error)
146
234
  }
147
235
  }
148
236
 
237
+ /**
238
+ * Registers an application callback for message decoding or dispatch failures.
239
+ */
149
240
  onMessageError(handler: <%= ctx.className %>ErrorHandler): Unsubscribe {
150
241
  this.messageErrorHandlers.add(handler)
151
242
  return () => {
@@ -153,12 +244,22 @@ export class <%= ctx.className %> {
153
244
  }
154
245
  }
155
246
 
156
- async socketError(error: unknown): Promise<void> {
247
+ /**
248
+ * Framework entrypoint for transport-level socket errors.
249
+ */
250
+ async handleSocketError(error: unknown): Promise<void> {
251
+ const sessionError = toOpenWsError(error)
252
+ for (const connection of this.connections) {
253
+ await connection.session.error(sessionError)
254
+ }
157
255
  for (const handler of this.socketErrorHandlers) {
158
256
  await handler(error)
159
257
  }
160
258
  }
161
259
 
260
+ /**
261
+ * Registers an application callback for transport-level socket errors.
262
+ */
162
263
  onSocketError(handler: <%= ctx.className %>ErrorHandler): Unsubscribe {
163
264
  this.socketErrorHandlers.add(handler)
164
265
  return () => {
@@ -166,15 +267,117 @@ export class <%= ctx.className %> {
166
267
  }
167
268
  }
168
269
 
270
+ /**
271
+ * Framework entrypoint for transport-level socket close events.
272
+ */
273
+ async handleSocketClose(_event: unknown): Promise<void> {
274
+ for (const connection of Array.from(this.connections)) {
275
+ await this.closeConnection(connection)
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Framework entrypoint for a peer session opening.
281
+ */
282
+ async handleOpen(roleName: string): Promise<void> {
283
+ for (const handler of this.openHandlers) {
284
+ await handler(roleName)
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Registers an application callback for peer session open events.
290
+ */
291
+ onOpen(handler: <%= ctx.className %>LifecycleHandler): Unsubscribe {
292
+ this.openHandlers.add(handler)
293
+ return () => {
294
+ this.openHandlers.delete(handler)
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Framework entrypoint for a peer session closing.
300
+ */
301
+ async handleClose(roleName: string): Promise<void> {
302
+ for (const handler of this.closeHandlers) {
303
+ await handler(roleName)
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Registers an application callback for peer session close events.
309
+ */
310
+ onClose(handler: <%= ctx.className %>LifecycleHandler): Unsubscribe {
311
+ this.closeHandlers.add(handler)
312
+ return () => {
313
+ this.closeHandlers.delete(handler)
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Framework entrypoint for a peer session error.
319
+ */
320
+ async handleError(roleName: string, error: Error): Promise<void> {
321
+ for (const handler of this.lifecycleErrorHandlers) {
322
+ await handler(roleName, error)
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Registers an application callback for peer session errors.
328
+ */
329
+ onError(handler: <%= ctx.className %>LifecycleErrorHandler): Unsubscribe {
330
+ this.lifecycleErrorHandlers.add(handler)
331
+ return () => {
332
+ this.lifecycleErrorHandlers.delete(handler)
333
+ }
334
+ }
335
+
336
+ private async closeSessions(roleName: string): Promise<void> {
337
+ for (const connection of this.findConnections(roleName)) {
338
+ await this.closeConnection(connection)
339
+ }
340
+ }
341
+
342
+ private async closeConnection(connection: <%= ctx.className %>Connection): Promise<void> {
343
+ if (!this.connections.delete(connection)) {
344
+ return
345
+ }
346
+ if (this.findConnections(connection.roleName).length === 0) {
347
+ for (const [messageName, peerRoleName] of Object.entries(this.peerRoleByMessageName)) {
348
+ if (peerRoleName !== connection.roleName) {
349
+ continue
350
+ }
351
+ delete this.peersByMessageName[messageName]
352
+ delete this.peerRoleByMessageName[messageName]
353
+ }
354
+ }
355
+ await connection.session.close()
356
+ }
357
+
358
+ private findConnections(roleName: string): <%= ctx.className %>Connection[] {
359
+ return Array.from(this.connections).filter(connection => connection.roleName === roleName)
360
+ }
361
+
362
+ private findConnectionByPeer(peer: PeerProto): <%= ctx.className %>Connection | undefined {
363
+ return Array.from(this.connections).find(connection => connection.peer === peer)
364
+ }
365
+
169
366
  <% for (const handler of ctx.handlers) { -%>
170
- <% const handlerApiType = handler.bindFromRoles.map(fromRole => ctx.remoteRoles.find(remoteRole => remoteRole.roleName === fromRole.roleName)?.scopedApiName).filter(Boolean).join(' | ') || `${ctx.className}PeerApi` -%>
171
- async <%= handler.dispatchMethodName %>(payload: <%= handler.payloadType %>, api: <%= handlerApiType %>): Promise<void> {
367
+ <% const handlerPeerType = handler.bindFromRoles.map(fromRole => ctx.remoteRoles.find(remoteRole => remoteRole.roleName === fromRole.roleName)?.scopedPeerName).filter(Boolean).join(' | ') || `${ctx.className}Peer` -%>
368
+ /**
369
+ * Dispatches the <%- JSON.stringify(handler.messageName) %> message to registered application callbacks.
370
+ */
371
+ async <%= handler.dispatchMethodName %>(payload: <%= handler.payloadType %>, peer: <%= handlerPeerType %>): Promise<void> {
172
372
  for (const handler of this.<%= handler.listenerFieldName %>) {
173
- await handler(payload, api)
373
+ await handler(payload, peer)
174
374
  }
175
375
  }
176
376
 
177
- <%= handler.onMethodName %>(handler: <%= ctx.className %>MessageHandler<<%= handler.payloadType %>, <%= handlerApiType %>>): Unsubscribe {
377
+ /**
378
+ * Registers an application callback for the <%- JSON.stringify(handler.messageName) %> message.
379
+ */
380
+ <%= handler.onMethodName %>(handler: <%= ctx.className %>MessageHandler<<%= handler.payloadType %>, <%= handlerPeerType %>>): Unsubscribe {
178
381
  this.<%= handler.listenerFieldName %>.add(handler)
179
382
  return () => {
180
383
  this.<%= handler.listenerFieldName %>.delete(handler)
@@ -182,28 +385,46 @@ export class <%= ctx.className %> {
182
385
  }
183
386
 
184
387
  <% } -%>
388
+ /**
389
+ * Framework entrypoint for a raw transport message.
390
+ */
185
391
  async handleRawMessage(data: string): Promise<void> {
186
392
  await this.handleMessage(decodeEnvelope(data))
187
393
  }
188
394
 
395
+ /**
396
+ * Framework entrypoint for a decoded OpenWS envelope.
397
+ */
189
398
  async handleMessage(envelope: OpenWsEnvelope): Promise<void> {
190
- const remote = this.binder.fromRoles[envelope.fromRole]
191
- const api = this.apisByRole[envelope.fromRole]
192
- if (remote && api) {
193
- await remote.handleMessage(envelope.messageName, envelope.payload, api)
399
+ const connections = this.findConnections(envelope.fromRole)
400
+ if (connections.length === 1) {
401
+ await connections[0].session.handleMessage(
402
+ envelope.fromRole,
403
+ envelope.messageName,
404
+ envelope.payload
405
+ )
194
406
  return
195
407
  }
408
+ if (connections.length > 1) {
409
+ throw new Error(
410
+ `Multiple sessions for remote role ${envelope.fromRole}; dispatch requires connection context`
411
+ )
412
+ }
196
413
 
197
414
  const localHandler = this.handlersByMessageName[envelope.messageName]
198
- const localApi = this.apisByMessageName[envelope.messageName]
199
- if (envelope.fromRole === this.fromRole && localHandler && localApi) {
200
- await localHandler(envelope.payload, localApi as unknown as <%= ctx.className %>PeerApi)
415
+ const localPeer = this.peersByMessageName[envelope.messageName]
416
+ if (envelope.fromRole === this.fromRole && localHandler && localPeer) {
417
+ await localHandler(envelope.payload, localPeer as unknown as <%= ctx.className %>Peer)
201
418
  return
202
419
  }
203
420
 
204
421
  throw new Error(`Remote role ${envelope.fromRole} not found`)
205
422
  }
206
423
  }
424
+
425
+ function toOpenWsError(error: unknown): Error {
426
+ return error instanceof Error ? error : new Error(String(error))
427
+ }
207
428
  <% } else { -%>
208
429
  import * as WS from '@polytric/openws/class'
209
430
  import * as Fluent from '@polytric/openws/fluent'
@@ -218,6 +439,13 @@ import {
218
439
  <% } -%>
219
440
  } from '<%= rolesImport %>'
220
441
 
442
+ /**
443
+ * Generated OpenWS client for the <%- JSON.stringify(ctx.roleName) %> role in the <%- JSON.stringify(ctx.networkName) %> network.
444
+ *
445
+ * Application code calls command methods such as `connect` and registers
446
+ * callbacks with `on...` methods. Transport and framework glue call `handle...`
447
+ * methods to deliver inbound data, errors, and lifecycle events.
448
+ */
221
449
  export class <%= ctx.className %> {
222
450
  static CONFIG = <%= ctx.hostRoleClassName %>.CONFIG
223
451
 
@@ -226,11 +454,15 @@ export class <%= ctx.className %> {
226
454
  runtime
227
455
  sendEnvelope
228
456
  #transportUnsubscribe
229
- #apisByRole = {}
230
- #apisByMessageName = {}
457
+ #connections = new Set()
458
+ #peersByMessageName = {}
459
+ #peerRoleByMessageName = {}
231
460
  #handlersByMessageName = {}
232
461
  #messageErrorHandlers = new Set()
233
462
  #socketErrorHandlers = new Set()
463
+ #openHandlers = new Set()
464
+ #closeHandlers = new Set()
465
+ #lifecycleErrorHandlers = new Set()
234
466
  #fromRole = <%- JSON.stringify(ctx.roleName) %>
235
467
  <% for (const handler of ctx.handlers) { -%>
236
468
  #<%= handler.listenerFieldName %> = new Set()
@@ -252,37 +484,55 @@ export class <%= ctx.className %> {
252
484
  await this.transport.send(encodeEnvelope({ fromRole: this.#fromRole, messageName, payload }))
253
485
  }
254
486
  <% for (const handler of ctx.handlers) { -%>
255
- this.#handlersByMessageName[<%- JSON.stringify(handler.messageName) %>] = async (payload, api) => {
256
- await this.<%= handler.dispatchMethodName %>(payload, api)
487
+ this.#handlersByMessageName[<%- JSON.stringify(handler.messageName) %>] = async (payload, peer) => {
488
+ await this.<%= handler.dispatchMethodName %>(payload, peer)
257
489
  }
258
490
  <% for (const fromRole of handler.bindFromRoles) { -%>
259
- this.binder.fromRoles[<%- JSON.stringify(fromRole.roleName) %>].on(<%- JSON.stringify(handler.messageName) %>, async (payload, api) => {
260
- await this.<%= handler.dispatchMethodName %>(payload, api)
491
+ this.binder.fromRoles[<%- JSON.stringify(fromRole.roleName) %>].on(<%- JSON.stringify(handler.messageName) %>, async (payload, peer) => {
492
+ await this.<%= handler.dispatchMethodName %>(payload, peer)
261
493
  })
262
494
  <% } -%>
495
+ <% } -%>
496
+ <% for (const remoteRole of ctx.remoteRoles) { -%>
497
+ this.binder.fromRoles[<%- JSON.stringify(remoteRole.roleName) %>].onOpen(async fromRole => {
498
+ await this.handleOpen(fromRole)
499
+ })
500
+ this.binder.fromRoles[<%- JSON.stringify(remoteRole.roleName) %>].onClose(async fromRole => {
501
+ await this.handleClose(fromRole)
502
+ })
503
+ this.binder.fromRoles[<%- JSON.stringify(remoteRole.roleName) %>].onError(async (fromRole, error) => {
504
+ await this.handleError(fromRole, error)
505
+ })
263
506
  <% } -%>
264
507
  if (canBindTransport(transport)) {
265
508
  this.bindTransport(transport)
266
509
  }
267
510
  }
268
511
 
512
+ /**
513
+ * Connects this client to a role and returns the connected peer handle.
514
+ */
269
515
  async connect(roleName, endpoint) {
270
516
  switch (roleName) {
271
517
  <% for (const remoteRole of ctx.remoteRoles) { -%>
272
518
  case <%- JSON.stringify(remoteRole.roleName) %>: {
273
519
  const remoteEndpoint = endpoint ?? <%- remoteRole.endpoints.length > 0 ? JSON.stringify(remoteRole.endpoints[0]) : 'undefined' %>
274
520
  await this.transport.connect?.(roleName, remoteEndpoint)
275
- if (!this.#apisByRole[<%- JSON.stringify(remoteRole.roleName) %>]) {
276
- this.<%= remoteRole.apiVarName %> = this.runtime.createApi(<%- JSON.stringify(remoteRole.roleName) %>, this.sendEnvelope)
277
- this.#apisByRole[<%- JSON.stringify(remoteRole.roleName) %>] = this.<%= remoteRole.apiVarName %>
521
+ const session = this.runtime.newSession(this.sendEnvelope)
522
+ this.<%= remoteRole.peerVarName %> = await session.open(<%- JSON.stringify(remoteRole.roleName) %>)
523
+ this.#connections.add({
524
+ roleName: <%- JSON.stringify(remoteRole.roleName) %>,
525
+ session,
526
+ peer: this.<%= remoteRole.peerVarName %>,
527
+ })
278
528
  <% for (const handler of ctx.handlers) { -%>
279
- <% const handlerDefaultApi = handler.bindFromRoles.find(fromRole => ctx.remoteRoles.some(remoteRole => remoteRole.roleName === fromRole.roleName)) ?? ctx.remoteRoles[0] -%>
280
- <% if (handlerDefaultApi?.roleName === remoteRole.roleName) { -%>
281
- this.#apisByMessageName[<%- JSON.stringify(handler.messageName) %>] = this.<%= remoteRole.apiVarName %>
529
+ <% const handlerDefaultPeer = handler.bindFromRoles.find(fromRole => ctx.remoteRoles.some(remoteRole => remoteRole.roleName === fromRole.roleName)) ?? ctx.remoteRoles[0] -%>
530
+ <% if (handlerDefaultPeer?.roleName === remoteRole.roleName) { -%>
531
+ this.#peersByMessageName[<%- JSON.stringify(handler.messageName) %>] = this.<%= remoteRole.peerVarName %>
532
+ this.#peerRoleByMessageName[<%- JSON.stringify(handler.messageName) %>] = <%- JSON.stringify(remoteRole.roleName) %>
282
533
  <% } -%>
283
534
  <% } -%>
284
- }
285
- return this.<%= remoteRole.apiVarName %>
535
+ return this.<%= remoteRole.peerVarName %>
286
536
  }
287
537
  <% } -%>
288
538
  default:
@@ -290,28 +540,57 @@ export class <%= ctx.className %> {
290
540
  }
291
541
  }
292
542
 
293
- async disconnect(roleName) {
294
- await this.transport.disconnect?.(roleName)
295
- delete this.#apisByRole[roleName]
543
+ /**
544
+ * Disconnects from a peer and closes its session.
545
+ *
546
+ * Pass the peer returned by `connect` to close that exact peer connection.
547
+ * Passing a role name closes all active peer connections for that role.
548
+ */
549
+ async disconnect(peer) {
550
+ if (typeof peer === 'string') {
551
+ await this.transport.disconnect?.(peer)
552
+ await this.#closeSessions(peer)
553
+ return
554
+ }
555
+ const connection = this.#findConnectionByPeer(peer)
556
+ if (!connection) {
557
+ throw new Error('Peer is not connected')
558
+ }
559
+ await this.#closeConnection(connection)
296
560
  }
297
561
 
562
+ /**
563
+ * Binds a transport to this client.
564
+ *
565
+ * Transport events call this client's `handle...` methods. Application code
566
+ * should use `on...` methods to observe those events.
567
+ */
298
568
  bindTransport(transport = this.transport, options = {}) {
299
569
  this.#transportUnsubscribe?.()
300
570
  this.#transportUnsubscribe = bindTransport(transport, this, options)
301
571
  return this.#transportUnsubscribe
302
572
  }
303
573
 
574
+ /**
575
+ * Removes the current transport event bindings.
576
+ */
304
577
  unbindTransport() {
305
578
  this.#transportUnsubscribe?.()
306
579
  this.#transportUnsubscribe = undefined
307
580
  }
308
581
 
309
- async messageError(error) {
582
+ /**
583
+ * Framework entrypoint for message decoding or dispatch failures.
584
+ */
585
+ async handleMessageError(error) {
310
586
  for (const handler of this.#messageErrorHandlers) {
311
587
  await handler(error)
312
588
  }
313
589
  }
314
590
 
591
+ /**
592
+ * Registers an application callback for message decoding or dispatch failures.
593
+ */
315
594
  onMessageError(handler) {
316
595
  this.#messageErrorHandlers.add(handler)
317
596
  return () => {
@@ -319,12 +598,22 @@ export class <%= ctx.className %> {
319
598
  }
320
599
  }
321
600
 
322
- async socketError(error) {
601
+ /**
602
+ * Framework entrypoint for transport-level socket errors.
603
+ */
604
+ async handleSocketError(error) {
605
+ const sessionError = toOpenWsError(error)
606
+ for (const connection of this.#connections) {
607
+ await connection.session.error(sessionError)
608
+ }
323
609
  for (const handler of this.#socketErrorHandlers) {
324
610
  await handler(error)
325
611
  }
326
612
  }
327
613
 
614
+ /**
615
+ * Registers an application callback for transport-level socket errors.
616
+ */
328
617
  onSocketError(handler) {
329
618
  this.#socketErrorHandlers.add(handler)
330
619
  return () => {
@@ -332,13 +621,115 @@ export class <%= ctx.className %> {
332
621
  }
333
622
  }
334
623
 
624
+ /**
625
+ * Framework entrypoint for transport-level socket close events.
626
+ */
627
+ async handleSocketClose(_event) {
628
+ for (const connection of Array.from(this.#connections)) {
629
+ await this.#closeConnection(connection)
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Framework entrypoint for a peer session opening.
635
+ */
636
+ async handleOpen(roleName) {
637
+ for (const handler of this.#openHandlers) {
638
+ await handler(roleName)
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Registers an application callback for peer session open events.
644
+ */
645
+ onOpen(handler) {
646
+ this.#openHandlers.add(handler)
647
+ return () => {
648
+ this.#openHandlers.delete(handler)
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Framework entrypoint for a peer session closing.
654
+ */
655
+ async handleClose(roleName) {
656
+ for (const handler of this.#closeHandlers) {
657
+ await handler(roleName)
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Registers an application callback for peer session close events.
663
+ */
664
+ onClose(handler) {
665
+ this.#closeHandlers.add(handler)
666
+ return () => {
667
+ this.#closeHandlers.delete(handler)
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Framework entrypoint for a peer session error.
673
+ */
674
+ async handleError(roleName, error) {
675
+ for (const handler of this.#lifecycleErrorHandlers) {
676
+ await handler(roleName, error)
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Registers an application callback for peer session errors.
682
+ */
683
+ onError(handler) {
684
+ this.#lifecycleErrorHandlers.add(handler)
685
+ return () => {
686
+ this.#lifecycleErrorHandlers.delete(handler)
687
+ }
688
+ }
689
+
690
+ async #closeSessions(roleName) {
691
+ for (const connection of this.#findConnections(roleName)) {
692
+ await this.#closeConnection(connection)
693
+ }
694
+ }
695
+
696
+ async #closeConnection(connection) {
697
+ if (!this.#connections.delete(connection)) {
698
+ return
699
+ }
700
+ if (this.#findConnections(connection.roleName).length === 0) {
701
+ for (const [messageName, peerRoleName] of Object.entries(this.#peerRoleByMessageName)) {
702
+ if (peerRoleName !== connection.roleName) {
703
+ continue
704
+ }
705
+ delete this.#peersByMessageName[messageName]
706
+ delete this.#peerRoleByMessageName[messageName]
707
+ }
708
+ }
709
+ await connection.session.close()
710
+ }
711
+
712
+ #findConnections(roleName) {
713
+ return Array.from(this.#connections).filter(connection => connection.roleName === roleName)
714
+ }
715
+
716
+ #findConnectionByPeer(peer) {
717
+ return Array.from(this.#connections).find(connection => connection.peer === peer)
718
+ }
719
+
335
720
  <% for (const handler of ctx.handlers) { -%>
336
- async <%= handler.dispatchMethodName %>(payload, api) {
721
+ /**
722
+ * Dispatches the <%- JSON.stringify(handler.messageName) %> message to registered application callbacks.
723
+ */
724
+ async <%= handler.dispatchMethodName %>(payload, peer) {
337
725
  for (const handler of this.#<%= handler.listenerFieldName %>) {
338
- await handler(payload, api)
726
+ await handler(payload, peer)
339
727
  }
340
728
  }
341
729
 
730
+ /**
731
+ * Registers an application callback for the <%- JSON.stringify(handler.messageName) %> message.
732
+ */
342
733
  <%= handler.onMethodName %>(handler) {
343
734
  this.#<%= handler.listenerFieldName %>.add(handler)
344
735
  return () => {
@@ -347,26 +738,44 @@ export class <%= ctx.className %> {
347
738
  }
348
739
 
349
740
  <% } -%>
741
+ /**
742
+ * Framework entrypoint for a raw transport message.
743
+ */
350
744
  async handleRawMessage(data) {
351
745
  await this.handleMessage(decodeEnvelope(data))
352
746
  }
353
747
 
748
+ /**
749
+ * Framework entrypoint for a decoded OpenWS envelope.
750
+ */
354
751
  async handleMessage(envelope) {
355
- const remote = this.binder.fromRoles[envelope.fromRole]
356
- const api = this.#apisByRole[envelope.fromRole]
357
- if (remote && api) {
358
- await remote.handleMessage(envelope.messageName, envelope.payload, api)
752
+ const connections = this.#findConnections(envelope.fromRole)
753
+ if (connections.length === 1) {
754
+ await connections[0].session.handleMessage(
755
+ envelope.fromRole,
756
+ envelope.messageName,
757
+ envelope.payload
758
+ )
359
759
  return
360
760
  }
761
+ if (connections.length > 1) {
762
+ throw new Error(
763
+ `Multiple sessions for remote role ${envelope.fromRole}; dispatch requires connection context`
764
+ )
765
+ }
361
766
 
362
767
  const localHandler = this.#handlersByMessageName[envelope.messageName]
363
- const localApi = this.#apisByMessageName[envelope.messageName]
364
- if (envelope.fromRole === this.#fromRole && localHandler && localApi) {
365
- await localHandler(envelope.payload, localApi)
768
+ const localPeer = this.#peersByMessageName[envelope.messageName]
769
+ if (envelope.fromRole === this.#fromRole && localHandler && localPeer) {
770
+ await localHandler(envelope.payload, localPeer)
366
771
  return
367
772
  }
368
773
 
369
774
  throw new Error(`Remote role ${envelope.fromRole} not found`)
370
775
  }
371
776
  }
777
+
778
+ function toOpenWsError(error) {
779
+ return error instanceof Error ? error : new Error(String(error))
780
+ }
372
781
  <% } -%>