@polytric/openws-sdkgen 0.0.13 → 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.
package/README.md CHANGED
@@ -41,7 +41,7 @@ The CLI exposes the following options:
41
41
  - `--out` (string): Output directory for generated code.
42
42
  - `--project` (string): Project/namespace prefix for generated code.
43
43
  - `--network` (string): Network name to generate.
44
- - `--hostRole` (string): Participant role name that represents the host side.
44
+ - `--hostRole` (string): Peer role name that represents the host side.
45
45
  - `--language` (string): Target language (`csharp`, `javascript`, or `typescript`).
46
46
  - `--environment` (string|array): Target environment (`unity`, `node`, `browser`).
47
47
 
package/dist/main.cjs CHANGED
@@ -457,7 +457,7 @@ function parseInput(ctx) {
457
457
  }).option("hostRole", {
458
458
  type: "array",
459
459
  string: true,
460
- description: "The target participant roles that use the generated code",
460
+ description: "The target peer roles that use the generated code",
461
461
  demandOption: true
462
462
  }).option("language", {
463
463
  type: "string",
package/dist/main.js CHANGED
@@ -434,7 +434,7 @@ function parseInput(ctx) {
434
434
  }).option("hostRole", {
435
435
  type: "array",
436
436
  string: true,
437
- description: "The target participant roles that use the generated code",
437
+ description: "The target peer roles that use the generated code",
438
438
  demandOption: true
439
439
  }).option("language", {
440
440
  type: "string",
@@ -216,7 +216,7 @@ function createPlan(ctx) {
216
216
  const remoteRoles = getPeerRoles(networkSpec, rolesByName, hostRole.roleName).map(
217
217
  (remoteRole) => ({
218
218
  ...remoteRole,
219
- scopedApiName: `${hostRole.className}${remoteRole.className}Api`,
219
+ scopedPeerName: `${hostRole.className}${remoteRole.className}Peer`,
220
220
  allowedMethodNames: getAllowedMessageMethodNames(
221
221
  networkSpec,
222
222
  remoteRole.roleName,
@@ -342,9 +342,9 @@ function toRoleInfo(role) {
342
342
  className,
343
343
  roleClassName: className,
344
344
  hostRoleClassName: `${className}Host`,
345
- apiName: `${className}Api`,
345
+ peerName: `${className}Peer`,
346
346
  varName: camelCase(role.name),
347
- apiVarName: `${camelCase(role.name)}Api`,
347
+ peerVarName: `${camelCase(role.name)}Peer`,
348
348
  fileName,
349
349
  roleFileName: `${fileName}-role`,
350
350
  description: role.description || "",
@@ -176,7 +176,7 @@ function createPlan(ctx) {
176
176
  const remoteRoles = getPeerRoles(networkSpec, rolesByName, hostRole.roleName).map(
177
177
  (remoteRole) => ({
178
178
  ...remoteRole,
179
- scopedApiName: `${hostRole.className}${remoteRole.className}Api`,
179
+ scopedPeerName: `${hostRole.className}${remoteRole.className}Peer`,
180
180
  allowedMethodNames: getAllowedMessageMethodNames(
181
181
  networkSpec,
182
182
  remoteRole.roleName,
@@ -302,9 +302,9 @@ function toRoleInfo(role) {
302
302
  className,
303
303
  roleClassName: className,
304
304
  hostRoleClassName: `${className}Host`,
305
- apiName: `${className}Api`,
305
+ peerName: `${className}Peer`,
306
306
  varName: camelCase(role.name),
307
- apiVarName: `${camelCase(role.name)}Api`,
307
+ peerVarName: `${camelCase(role.name)}Peer`,
308
308
  fileName,
309
309
  roleFileName: `${fileName}-role`,
310
310
  description: role.description || "",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "files": ["dist"],
25
25
  "dependencies": {
26
- "@polytric/openws": "^0.0.6"
26
+ "@polytric/openws": "^0.0.7"
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsup",
@@ -9,12 +9,18 @@ export const endpoints = <%- JSON.stringify(
9
9
  ) %>
10
10
 
11
11
  <% if (ctx.isTypeScript) { -%>
12
+ /**
13
+ * Wire-format message envelope used by this OpenWS SDK.
14
+ */
12
15
  export interface OpenWsEnvelope<Payload = unknown> {
13
16
  fromRole: string
14
17
  messageName: string
15
18
  payload: Payload
16
19
  }
17
20
 
21
+ /**
22
+ * Connection endpoint metadata from the OpenWS spec.
23
+ */
18
24
  export interface OpenWsEndpoint {
19
25
  scheme: string
20
26
  host?: string
@@ -26,6 +32,12 @@ export type Unsubscribe = () => void
26
32
  export type TransportEvent = 'message' | 'error' | 'close'
27
33
  export type TransportHandler = (data: unknown) => void | Promise<void>
28
34
 
35
+ /**
36
+ * Minimal transport contract used by generated SDK clients.
37
+ *
38
+ * Implement this interface to plug in a custom socket, browser WebSocket,
39
+ * server-side WebSocket client, or test transport.
40
+ */
29
41
  export interface Transport {
30
42
  send(data: string): void | Promise<void>
31
43
  on?(event: TransportEvent, handler: TransportHandler): unknown
@@ -38,24 +50,47 @@ export interface BindTransportOptions {
38
50
  closeOnError?: boolean
39
51
  }
40
52
 
53
+ /**
54
+ * Callback surface used by `bindTransport`.
55
+ *
56
+ * `handle...` methods are framework entrypoints called by transport glue.
57
+ * Generated clients expose matching `on...` methods for application callbacks.
58
+ */
41
59
  export interface RawMessageHandler {
42
60
  handleRawMessage(data: string): void | Promise<void>
43
- messageError?(error: unknown): void | Promise<void>
44
- socketError?(error: unknown): void | Promise<void>
61
+ handleMessageError?(error: unknown): void | Promise<void>
62
+ handleSocketError?(error: unknown): void | Promise<void>
63
+ handleSocketClose?(event: unknown): void | Promise<void>
45
64
  }
46
65
 
66
+ /**
67
+ * Encodes an OpenWS envelope for transport.
68
+ */
47
69
  export function encodeEnvelope(envelope: OpenWsEnvelope): string {
48
70
  return JSON.stringify(envelope)
49
71
  }
50
72
 
73
+ /**
74
+ * Decodes raw transport data into an OpenWS envelope.
75
+ */
51
76
  export function decodeEnvelope(data: string): OpenWsEnvelope {
52
77
  return JSON.parse(data) as OpenWsEnvelope
53
78
  }
54
79
 
80
+ /**
81
+ * Returns true when a transport can be bound to generated client callbacks.
82
+ */
55
83
  export function canBindTransport(transport: Transport): boolean {
56
84
  return typeof transport.on === 'function'
57
85
  }
58
86
 
87
+ /**
88
+ * Binds transport events to a generated client or compatible raw message handler.
89
+ *
90
+ * Transport `message` events call `handleRawMessage`, message handling failures
91
+ * call `handleMessageError`, transport `error` events call `handleSocketError`,
92
+ * and transport `close` events call `handleSocketClose`.
93
+ */
59
94
  export function bindTransport(
60
95
  transport: Transport,
61
96
  handler: RawMessageHandler,
@@ -65,14 +100,17 @@ export function bindTransport(
65
100
  try {
66
101
  await handler.handleRawMessage(await normalizeMessageData(data))
67
102
  } catch (error) {
68
- await handler.messageError?.(error)
103
+ await handler.handleMessageError?.(error)
69
104
  if (options.closeOnError) {
70
105
  transport.close?.()
71
106
  }
72
107
  }
73
108
  }
74
109
  const handleSocketError = async (error: unknown) => {
75
- await handler.socketError?.(error)
110
+ await handler.handleSocketError?.(error)
111
+ }
112
+ const handleSocketClose = async (event: unknown) => {
113
+ await handler.handleSocketClose?.(event)
76
114
  }
77
115
 
78
116
  const nodeHandler = (data: unknown, ..._args: unknown[]) => {
@@ -81,6 +119,9 @@ export function bindTransport(
81
119
  const nodeErrorHandler = (error: unknown, ..._args: unknown[]) => {
82
120
  void handleSocketError(error)
83
121
  }
122
+ const nodeCloseHandler = (event: unknown, ..._args: unknown[]) => {
123
+ void handleSocketClose(event)
124
+ }
84
125
 
85
126
  if (typeof transport.on !== 'function') {
86
127
  throw new Error('Transport must support on("message")')
@@ -88,12 +129,17 @@ export function bindTransport(
88
129
 
89
130
  const messageUnsubscribe = transport.on('message', nodeHandler)
90
131
  const errorUnsubscribe = transport.on('error', nodeErrorHandler)
132
+ const closeUnsubscribe = transport.on('close', nodeCloseHandler)
91
133
  return () => {
92
134
  if (typeof messageUnsubscribe === 'function') messageUnsubscribe()
93
135
  if (typeof errorUnsubscribe === 'function') errorUnsubscribe()
136
+ if (typeof closeUnsubscribe === 'function') closeUnsubscribe()
94
137
  }
95
138
  }
96
139
 
140
+ /**
141
+ * WebSocket-backed transport implementation for generated SDK clients.
142
+ */
97
143
  export class WsTransport implements Transport {
98
144
  private socket?: unknown
99
145
  private socketUnsubscribe?: Unsubscribe
@@ -110,6 +156,9 @@ export class WsTransport implements Transport {
110
156
  }
111
157
  }
112
158
 
159
+ /**
160
+ * Opens the underlying WebSocket when needed and waits until it is ready.
161
+ */
113
162
  async connect(_roleName: string, endpoint?: OpenWsEndpoint): Promise<void> {
114
163
  if (!this.socket) {
115
164
  if (!endpoint) {
@@ -120,10 +169,16 @@ export class WsTransport implements Transport {
120
169
  await this.waitForOpen()
121
170
  }
122
171
 
172
+ /**
173
+ * Closes the underlying WebSocket connection.
174
+ */
123
175
  async disconnect(): Promise<void> {
124
176
  this.close()
125
177
  }
126
178
 
179
+ /**
180
+ * Sends already-encoded OpenWS envelope data.
181
+ */
127
182
  async send(data: string): Promise<void> {
128
183
  await this.waitForOpen()
129
184
  const socket = this.requireSocket() as { send?: (data: string) => void | Promise<void> }
@@ -133,6 +188,9 @@ export class WsTransport implements Transport {
133
188
  await socket.send(data)
134
189
  }
135
190
 
191
+ /**
192
+ * Registers a transport event callback.
193
+ */
136
194
  on(event: TransportEvent, handler: TransportHandler): Unsubscribe {
137
195
  this.listeners[event].add(handler)
138
196
  return () => {
@@ -140,6 +198,9 @@ export class WsTransport implements Transport {
140
198
  }
141
199
  }
142
200
 
201
+ /**
202
+ * Closes and clears the current socket.
203
+ */
143
204
  close(): void {
144
205
  const socket = this.socket as { close?: () => void } | undefined
145
206
  socket?.close?.()
@@ -311,31 +372,50 @@ function addSocketListener(
311
372
  }
312
373
  }
313
374
  <% } else { -%>
375
+ /**
376
+ * Encodes an OpenWS envelope for transport.
377
+ */
314
378
  export function encodeEnvelope(envelope) {
315
379
  return JSON.stringify(envelope)
316
380
  }
317
381
 
382
+ /**
383
+ * Decodes raw transport data into an OpenWS envelope.
384
+ */
318
385
  export function decodeEnvelope(data) {
319
386
  return JSON.parse(data)
320
387
  }
321
388
 
389
+ /**
390
+ * Returns true when a transport can be bound to generated client callbacks.
391
+ */
322
392
  export function canBindTransport(transport) {
323
393
  return typeof transport.on === 'function'
324
394
  }
325
395
 
396
+ /**
397
+ * Binds transport events to a generated client or compatible raw message handler.
398
+ *
399
+ * Transport `message` events call `handleRawMessage`, message handling failures
400
+ * call `handleMessageError`, transport `error` events call `handleSocketError`,
401
+ * and transport `close` events call `handleSocketClose`.
402
+ */
326
403
  export function bindTransport(transport, handler, options = {}) {
327
404
  const handleData = async data => {
328
405
  try {
329
406
  await handler.handleRawMessage(await normalizeMessageData(data))
330
407
  } catch (error) {
331
- await handler.messageError?.(error)
408
+ await handler.handleMessageError?.(error)
332
409
  if (options.closeOnError) {
333
410
  transport.close?.()
334
411
  }
335
412
  }
336
413
  }
337
414
  const handleSocketError = async error => {
338
- await handler.socketError?.(error)
415
+ await handler.handleSocketError?.(error)
416
+ }
417
+ const handleSocketClose = async event => {
418
+ await handler.handleSocketClose?.(event)
339
419
  }
340
420
 
341
421
  const nodeHandler = data => {
@@ -344,6 +424,9 @@ export function bindTransport(transport, handler, options = {}) {
344
424
  const nodeErrorHandler = error => {
345
425
  void handleSocketError(error)
346
426
  }
427
+ const nodeCloseHandler = event => {
428
+ void handleSocketClose(event)
429
+ }
347
430
 
348
431
  if (typeof transport.on !== 'function') {
349
432
  throw new Error('Transport must support on("message")')
@@ -351,12 +434,17 @@ export function bindTransport(transport, handler, options = {}) {
351
434
 
352
435
  const messageUnsubscribe = transport.on('message', nodeHandler)
353
436
  const errorUnsubscribe = transport.on('error', nodeErrorHandler)
437
+ const closeUnsubscribe = transport.on('close', nodeCloseHandler)
354
438
  return () => {
355
439
  if (typeof messageUnsubscribe === 'function') messageUnsubscribe()
356
440
  if (typeof errorUnsubscribe === 'function') errorUnsubscribe()
441
+ if (typeof closeUnsubscribe === 'function') closeUnsubscribe()
357
442
  }
358
443
  }
359
444
 
445
+ /**
446
+ * WebSocket-backed transport implementation for generated SDK clients.
447
+ */
360
448
  export class WsTransport {
361
449
  socket
362
450
  socketUnsubscribe
@@ -373,6 +461,9 @@ export class WsTransport {
373
461
  }
374
462
  }
375
463
 
464
+ /**
465
+ * Opens the underlying WebSocket when needed and waits until it is ready.
466
+ */
376
467
  async connect(_roleName, endpoint) {
377
468
  if (!this.socket) {
378
469
  if (!endpoint) {
@@ -383,10 +474,16 @@ export class WsTransport {
383
474
  await this.waitForOpen()
384
475
  }
385
476
 
477
+ /**
478
+ * Closes the underlying WebSocket connection.
479
+ */
386
480
  async disconnect() {
387
481
  this.close()
388
482
  }
389
483
 
484
+ /**
485
+ * Sends already-encoded OpenWS envelope data.
486
+ */
390
487
  async send(data) {
391
488
  await this.waitForOpen()
392
489
  const socket = this.requireSocket()
@@ -396,6 +493,9 @@ export class WsTransport {
396
493
  await socket.send(data)
397
494
  }
398
495
 
496
+ /**
497
+ * Registers a transport event callback.
498
+ */
399
499
  on(event, handler) {
400
500
  this.listeners[event].add(handler)
401
501
  return () => {
@@ -403,6 +503,9 @@ export class WsTransport {
403
503
  }
404
504
  }
405
505
 
506
+ /**
507
+ * Closes and clears the current socket.
508
+ */
406
509
  close() {
407
510
  this.socket?.close?.()
408
511
  this.clearSocket()
@@ -10,7 +10,7 @@ import type { <%= payloadImports.join(', ') %> } from '<%= modelImportPath %>'
10
10
  import { <%= peerRoleImports.join(', ') %> } from './index'
11
11
  <% } -%>
12
12
 
13
- export interface <%= ctx.apiName %> {
13
+ export interface <%= ctx.peerName %> {
14
14
  <% for (const message of ctx.messages) { -%>
15
15
  <%= message.methodName %>(payload: <%= message.payloadType %> | <%= message.payloadType %>Init): Promise<void>
16
16
  <% } -%>
@@ -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
  <% } -%>
package/package.json CHANGED
@@ -1,65 +1,68 @@
1
1
  {
2
- "name": "@polytric/openws-sdkgen",
3
- "version": "0.0.13",
4
- "description": "OpenWS SDK generator CLI",
5
- "type": "module",
6
- "bin": {
7
- "openws-sdkgen": "./dist/main.cjs"
8
- },
9
- "files": [
10
- "dist",
11
- "LICENSE",
12
- "README.md"
13
- ],
14
- "keywords": [
15
- "openws",
16
- "sdk",
17
- "codegen",
18
- "websocket",
19
- "unity",
20
- "dotnet"
21
- ],
22
- "author": "Polytric",
23
- "license": "Apache-2.0",
24
- "engines": {
25
- "node": ">=20"
26
- },
27
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/AgeOfLearning/openws.git",
30
- "directory": "tooling/sdkgen"
31
- },
32
- "publishConfig": {
33
- "access": "public"
34
- },
35
- "dependencies": {
36
- "@pocketgems/schema": "^0.1.3",
37
- "ajv": "^8.17.1",
38
- "ejs": "^3.1.10",
39
- "prettier": "^3.7.4",
40
- "yargs": "^17.7.2",
41
- "@polytric/openws-spec": "^0.0.4"
42
- },
43
- "devDependencies": {
44
- "@types/ejs": "^3.1.5",
45
- "@types/node": "^22.15.29",
46
- "@types/ws": "^8.18.1",
47
- "@types/yargs": "^17.0.33",
48
- "fastify": "^5.6.2",
49
- "tsup": "^8.5.1",
50
- "tsx": "^4.21.0",
51
- "typescript": "^5.9.3",
52
- "ws": "^8.18.3",
53
- "@polytric/openws": "^0.0.6"
54
- },
55
- "scripts": {
56
- "build": "tsup",
57
- "typecheck": "tsc --noEmit",
58
- "test:csharp:unity": "node dist/main.cjs --spec ./test/spec.json --out ./generated/dotnet/unity --project Example --network core --hostRole client --language csharp --environment unity",
59
- "test:javascript:node": "node dist/main.cjs --spec ./test/spec.json --out ./generated/javascript/node --project Example --network core --hostRole client --language javascript --environment node",
60
- "test:typescript:node": "node dist/main.cjs --spec ./test/spec.json --out ./generated/typescript/node --project Example --network core --hostRole client --language typescript --environment node",
61
- "test:typescript:server": "tsx ./test/typescript/server.ts",
62
- "test:typescript:client": "tsx ./test/typescript/client.ts",
63
- "test:transport-close": "tsx ./test/typescript/transport-close.ts"
64
- }
65
- }
2
+ "name": "@polytric/openws-sdkgen",
3
+ "version": "0.0.14",
4
+ "description": "OpenWS SDK generator CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "openws-sdkgen": "./dist/main.cjs"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "LICENSE",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "openws",
16
+ "sdk",
17
+ "codegen",
18
+ "websocket",
19
+ "unity",
20
+ "dotnet"
21
+ ],
22
+ "author": "Polytric",
23
+ "license": "Apache-2.0",
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/AgeOfLearning/openws.git",
30
+ "directory": "tooling/sdkgen"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "prepublishOnly": "pnpm build",
38
+ "typecheck": "tsc --noEmit",
39
+ "test:csharp:unity": "node dist/main.cjs --spec ./test/spec.json --out ./generated/dotnet/unity --project Example --network core --hostRole client --language csharp --environment unity",
40
+ "test:javascript:node": "node dist/main.cjs --spec ./test/spec.json --out ./generated/javascript/node --project Example --network core --hostRole client --language javascript --environment node",
41
+ "test:typescript:node": "node dist/main.cjs --spec ./test/spec.json --out ./generated/typescript/node --project Example --network core --hostRole client --language typescript --environment node",
42
+ "test:typescript:server": "tsx ./test/typescript/server.ts",
43
+ "test:typescript:client": "tsx ./test/typescript/client.ts",
44
+ "test:client-lifecycle": "tsx ./test/typescript/client-lifecycle.ts",
45
+ "test:transport-close": "tsx ./test/typescript/transport-close.ts"
46
+ },
47
+ "dependencies": {
48
+ "@pocketgems/schema": "^0.1.3",
49
+ "@polytric/openws-spec": "workspace:^",
50
+ "ajv": "^8.17.1",
51
+ "ejs": "^3.1.10",
52
+ "prettier": "^3.7.4",
53
+ "yargs": "^17.7.2"
54
+ },
55
+ "devDependencies": {
56
+ "@polytric/openws": "workspace:^",
57
+ "@types/ejs": "^3.1.5",
58
+ "@types/node": "^22.15.29",
59
+ "@types/ws": "^8.18.1",
60
+ "@types/yargs": "^17.0.33",
61
+ "fastify": "^5.6.2",
62
+ "tsup": "^8.5.1",
63
+ "tsx": "^4.21.0",
64
+ "typescript": "^5.9.3",
65
+ "ws": "^8.18.3"
66
+ },
67
+ "packageManager": "pnpm@10.26.1"
68
+ }