@genesislcap/mock-server 15.51.0 → 15.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +217 -31
  2. package/dist/db/criteria.d.ts +1 -0
  3. package/dist/db/criteria.js +29 -0
  4. package/dist/db/identity.d.ts +11 -0
  5. package/dist/db/identity.js +46 -0
  6. package/dist/db/schema.d.ts +46 -0
  7. package/dist/db/schema.js +375 -0
  8. package/dist/db/store.d.ts +13 -2
  9. package/dist/db/store.js +208 -34
  10. package/dist/handlers/commitEvent.js +20 -6
  11. package/dist/handlers/dataLogon.js +126 -6
  12. package/dist/handlers/jsonSchema.d.ts +2 -1
  13. package/dist/handlers/jsonSchema.js +216 -61
  14. package/dist/handlers/meta.d.ts +6 -1
  15. package/dist/handlers/meta.js +164 -21
  16. package/dist/handlers/requestReply.js +16 -5
  17. package/dist/handlers/resourceAuth.d.ts +1 -0
  18. package/dist/handlers/resourceAuth.js +14 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +2 -1
  21. package/dist/protocol/connection.d.ts +2 -0
  22. package/dist/protocol/errors.d.ts +100 -2
  23. package/dist/protocol/errors.js +300 -12
  24. package/dist/protocol/fieldTypes.d.ts +9 -0
  25. package/dist/protocol/fieldTypes.js +101 -0
  26. package/dist/protocol/gsfSchemas.d.ts +9 -0
  27. package/dist/protocol/gsfSchemas.js +423 -0
  28. package/dist/protocol/messageTypes.d.ts +1 -0
  29. package/dist/protocol/messageTypes.js +1 -0
  30. package/dist/protocol/rowUpdate.d.ts +5 -3
  31. package/dist/protocol/rowUpdate.js +33 -15
  32. package/dist/server.js +90 -28
  33. package/dist/types.d.ts +40 -0
  34. package/package.json +1 -1
  35. package/src/db/criteria.ts +26 -0
  36. package/src/db/identity.ts +53 -0
  37. package/src/db/schema.ts +523 -0
  38. package/src/db/store.ts +225 -32
  39. package/src/handlers/commitEvent.ts +20 -7
  40. package/src/handlers/dataLogon.ts +144 -7
  41. package/src/handlers/jsonSchema.ts +290 -69
  42. package/src/handlers/meta.ts +222 -23
  43. package/src/handlers/requestReply.ts +15 -6
  44. package/src/handlers/resourceAuth.ts +18 -0
  45. package/src/index.ts +16 -1
  46. package/src/protocol/connection.ts +10 -2
  47. package/src/protocol/errors.ts +361 -13
  48. package/src/protocol/fieldTypes.ts +108 -0
  49. package/src/protocol/gsfSchemas.ts +505 -0
  50. package/src/protocol/messageTypes.ts +1 -0
  51. package/src/protocol/rowUpdate.ts +40 -20
  52. package/src/server.ts +101 -27
  53. package/src/types.ts +145 -1
package/src/server.ts CHANGED
@@ -3,11 +3,11 @@ import { WebSocketServer, type WebSocket } from 'ws';
3
3
  import { Store } from './db/store.ts';
4
4
  import { pendingKey } from './handlers/dataLogon.ts';
5
5
  import { createHttpRequestListener } from './handlers/http.ts';
6
- import { applyResourceAuth, requestingUser } from './handlers/resourceAuth.ts';
6
+ import { permittedQueryRow, requestingUser } from './handlers/resourceAuth.ts';
7
7
  import { Connection, type Subscription } from './protocol/connection.ts';
8
8
  import { isLegacyFidelity } from './protocol/fidelity.ts';
9
9
  import { routeMessage } from './protocol/router.ts';
10
- import { stampRow, type RowOperation } from './protocol/rowUpdate.ts';
10
+ import { dataserverRow, stampRow, type RowOperation } from './protocol/rowUpdate.ts';
11
11
  import type {
12
12
  BroadcastFn,
13
13
  BroadcastTableChangeFn,
@@ -141,9 +141,10 @@ export function createMockServer(config: MockServerConfig): MockServer {
141
141
  const permitted =
142
142
  operation === 'DELETE'
143
143
  ? undefined
144
- : applyResourceAuth([row], query?.auth, requestingUser(connection))[0];
144
+ : permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
145
145
  if (pending.has(key)) {
146
- if (permitted) pending.set(key, stampRow(permitted, rowRef, 'INSERT'));
146
+ if (permitted)
147
+ pending.set(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'));
147
148
  else pending.delete(key);
148
149
  return null;
149
150
  }
@@ -153,11 +154,22 @@ export function createMockServer(config: MockServerConfig): MockServer {
153
154
  return 'DELETE';
154
155
  }
155
156
  if (!permitted) return null;
157
+ // Under ORDER_BY, a new row that sorts among the waiting ones waits too,
158
+ // so MORE_ROWS pages stay in order.
159
+ if (
160
+ subscription.queueInsert?.(
161
+ key,
162
+ stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'),
163
+ row,
164
+ )
165
+ )
166
+ return null;
167
+
156
168
  held.add(key);
157
169
  return 'INSERT';
158
170
  }
159
171
 
160
- const broadcast: BroadcastFn = (resourceName, operation, row) => {
172
+ function pushChange(resourceName: string, operation: RowOperation, row: Row): void {
161
173
  const query = config.queries?.[resourceName];
162
174
  // The query's own filter (where-clause equivalent) applies to live pushes
163
175
  // too — a row the snapshot would exclude must not leak in via broadcast.
@@ -188,19 +200,27 @@ export function createMockServer(config: MockServerConfig): MockServer {
188
200
  ? trackClientView(subscription, connection, query, resolved, row, rowRef)
189
201
  : resolved;
190
202
  if (rowOperation === null) continue;
203
+ const legacy = isLegacyFidelity(config);
191
204
  let outRow = row;
192
205
  if (rowOperation === 'DELETE') {
193
- // A change turned into a DELETE: send the row identity only — it is
194
- // all the client needs to drop the row, and the full row may carry
195
- // columns this user's hideFields strips. (A real DELETE's row is a
196
- // stub already.)
197
- if (operation !== 'DELETE') {
206
+ // GSF's DELETE row is DETAILS {OPERATION, ROW_REF} and nothing else.
207
+ // Legacy mode sent the pk fields: the stub it was given for a real
208
+ // DELETE, or one rebuilt from the key for a change that left the
209
+ // subscription (the full row may carry columns hideFields strips).
210
+ if (!legacy) outRow = {};
211
+ else if (operation !== 'DELETE') {
198
212
  outRow = query ? store.rowStubFromKey(query.source, rowRef as string) : row;
199
213
  }
200
- } else if (query?.auth) {
201
- const [permitted] = applyResourceAuth([row], query.auth, requestingUser(connection));
214
+ } else {
215
+ // Only the subscription's columns, minus the ones this user can't see.
216
+ const permitted = permittedQueryRow(
217
+ row,
218
+ query?.auth,
219
+ requestingUser(connection),
220
+ subscription.fieldNames,
221
+ );
202
222
  if (!permitted) continue;
203
- outRow = permitted;
223
+ outRow = dataserverRow(permitted, row, query?.fields);
204
224
  }
205
225
  subscription.sequenceId += 1;
206
226
  // A live push carries neither MORE_ROWS nor ROWS_COUNT on the real
@@ -208,12 +228,33 @@ export function createMockServer(config: MockServerConfig): MockServer {
208
228
  connection.send({
209
229
  MESSAGE_TYPE: 'QUERY_UPDATE',
210
230
  SOURCE_REF: sourceRef,
211
- ROW: [stampRow(outRow, rowRef, rowOperation)],
212
- ...(isLegacyFidelity(config) ? { ROWS_COUNT: 1 } : {}),
231
+ ROW: [stampRow(outRow, rowRef, rowOperation, legacy)],
232
+ ...(legacy ? { ROWS_COUNT: 1 } : {}),
213
233
  SEQUENCE_ID: subscription.sequenceId,
214
234
  });
215
235
  }
216
236
  }
237
+ }
238
+
239
+ // Pushes an unsolicited row update to every subscription on the resource
240
+ // (see pushChange). A DELETE given as a pk-only row is resolved to the
241
+ // record(s) removed from that key that no broadcast has announced yet —
242
+ // their RECORD_IDs are the ROW_REFs a client may hold — and those are
243
+ // consumed, so a later delete of a re-inserted key names its own record.
244
+ const broadcast: BroadcastFn = (resourceName, operation, row) => {
245
+ const source = config.queries?.[resourceName]?.source;
246
+ if (operation !== 'DELETE' || !source) {
247
+ pushChange(resourceName, operation, row);
248
+ return;
249
+ }
250
+ const removed = store.takeRemovedRecordsFor(source, row);
251
+ if (isLegacyFidelity(config)) {
252
+ pushChange(resourceName, operation, row);
253
+ return;
254
+ }
255
+ const records = row.RECORD_ID === undefined || row.RECORD_ID === null ? removed : [row];
256
+ if (records.length === 0) records.push(row);
257
+ for (const record of records) pushChange(resourceName, 'DELETE', record);
217
258
  };
218
259
 
219
260
  // Event handlers mutate one table (e.g. TRADE) but grids may be subscribed to
@@ -221,8 +262,26 @@ export function createMockServer(config: MockServerConfig): MockServer {
221
262
  // query resource backed by that table — directly or via a view — and pushes
222
263
  // the (re-joined, for views) row to each one's subscribers. `pk` is the
223
264
  // store key: the single pk field's value, or store.rowKey(...) for
224
- // composite-keyed tables.
265
+ // composite-keyed tables. ROW_REF is the record's RECORD_ID, so a DELETE is
266
+ // broadcast with every record the key lost that no broadcast announced yet
267
+ // (Store.takeRemovedRows; held-rows tracking drops the ones a client
268
+ // doesn't hold), and a record replaced under the same key (deleted, then
269
+ // re-inserted, before one broadcast) gets a DELETE of its old ROW_REF before
270
+ // the new one goes out. A view row is built from the changed base row
271
+ // alone, so it is found whatever the view's own pkField; a MODIFY that takes
272
+ // it out of the view (an anti-join) is pushed as a DELETE. Legacy ROW_REFs
273
+ // are the key itself, so there a DELETE is the pk stub and a replacement is
274
+ // the one INSERT/MODIFY, as before.
225
275
  const broadcastTableChange: BroadcastTableChangeFn = (tableName, operation, pk) => {
276
+ const legacy = isLegacyFidelity(config);
277
+ const current = operation === 'DELETE' ? undefined : store.getRow(tableName, pk);
278
+ let removed = store
279
+ .takeRemovedRows(tableName, pk)
280
+ .filter((record) => record.RECORD_ID !== current?.RECORD_ID);
281
+ // A DELETE broadcast before the row is removed still names it.
282
+ const live = store.getRow(tableName, pk);
283
+ if (operation === 'DELETE' && removed.length === 0 && live) removed = [live];
284
+ if (legacy) removed = [];
226
285
  for (const [queryName, query] of Object.entries(config.queries ?? {})) {
227
286
  const isDirect = query.source === tableName;
228
287
  const view = store.views.get(query.source);
@@ -242,23 +301,38 @@ export function createMockServer(config: MockServerConfig): MockServer {
242
301
 
243
302
  if (isViewJoiningTable) {
244
303
  for (const viewRow of store.getViewRows(query.source)) {
245
- broadcast(queryName, 'MODIFY', viewRow);
304
+ pushChange(queryName, 'MODIFY', viewRow);
246
305
  }
247
306
  continue;
248
307
  }
249
308
 
250
- let row: Row | undefined;
309
+ // The removed base records carry the RECORD_ID a view row's ROW_REF
310
+ // comes from too; the DELETE body is DETAILS only either way.
311
+ for (const record of removed) pushChange(queryName, 'DELETE', record);
251
312
  if (operation === 'DELETE') {
252
- const stub = store.rowStubFromKey(query.source, pk);
253
- row = Object.keys(stub).length > 0 ? stub : undefined;
254
- } else if (isDirect) {
255
- row = store.getRow(tableName, pk);
256
- } else {
257
- row = store
258
- .getViewRows(query.source)
259
- .find((r) => String(store.getRowRef(query.source, r)) === String(pk));
313
+ if (legacy) {
314
+ const stub = store.rowStubFromKey(query.source, pk);
315
+ if (Object.keys(stub).length > 0) pushChange(queryName, 'DELETE', stub);
316
+ }
317
+ continue;
318
+ }
319
+ if (!current) continue;
320
+ if (isDirect) {
321
+ pushChange(queryName, operation, current);
322
+ continue;
323
+ }
324
+ const viewRow = store.getViewRows(query.source, { [tableName]: [current] })[0];
325
+ if (viewRow) {
326
+ pushChange(queryName, operation, viewRow);
327
+ } else if (operation === 'MODIFY') {
328
+ pushChange(
329
+ queryName,
330
+ 'DELETE',
331
+ legacy
332
+ ? store.rowStubFromKey(query.source, store.getRowRef(query.source, current) as string)
333
+ : current,
334
+ );
260
335
  }
261
- if (row) broadcast(queryName, operation, row);
262
336
  }
263
337
  };
264
338
 
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
6
6
  import type { Store } from './db/store.ts';
7
+ import type { NackErrorInput } from './protocol/errors.ts';
7
8
 
8
9
  export type Row = Record<string, any>;
9
10
 
@@ -14,6 +15,76 @@ export interface GenesisMessage {
14
15
  [key: string]: any;
15
16
  }
16
17
 
18
+ // The Genesis dictionary field types (global.genesis.dictionary.Field.Type).
19
+ export type GenesisFieldType =
20
+ | 'STRING'
21
+ | 'ENUM'
22
+ | 'INT'
23
+ | 'SHORT'
24
+ | 'LONG'
25
+ | 'DOUBLE'
26
+ | 'BIGDECIMAL'
27
+ | 'BOOLEAN'
28
+ | 'DATE'
29
+ | 'DATETIME'
30
+ | 'NANO_TIMESTAMP'
31
+ | 'RAW';
32
+
33
+ // One column of a table, as the project's tables dictionary declares it
34
+ // (`field("NAME", STRING(100)).notNull()`). META_REQUEST and
35
+ // JSON_SCHEMA_REQUEST are built from these when a table declares them; see the
36
+ // README's "Declared schema".
37
+ export interface FieldDef {
38
+ name: string;
39
+ type: GenesisFieldType;
40
+ // Default true, as in the dictionary (`.notNull()` makes it false). Primary
41
+ // key and generated fields default to false.
42
+ nullable?: boolean;
43
+ // ENUM values, in dictionary order.
44
+ values?: string[];
45
+ // The dictionary `.default(...)`. A non-null field with a default is
46
+ // optional in event schemas.
47
+ default?: unknown;
48
+ // STRING column size (`STRING(100)`): MAX_LENGTH / maxLength.
49
+ maxSize?: number;
50
+ // `metadata { minLength = 0 }`: MIN_LENGTH / minLength.
51
+ minLength?: number;
52
+ // Replaces the DESCRIPTION / description the real server fills with the
53
+ // field's JVM class (kotlin.String, org.joda.time.DateTime, ...). Leave it
54
+ // unset for DATE/DATETIME: foundation-forms recognises dates partly by that
55
+ // class name.
56
+ description?: string;
57
+ // Defaults to the name in title case ('COUNTERPARTY_ID' -> 'Counterparty Id').
58
+ title?: string;
59
+ // Whether an event payload may leave it out (OPTIONAL, and not in the JSON
60
+ // schema's `required`). The real server makes a field optional only when
61
+ // its class property has a default. Unset: a table field is optional when
62
+ // it is nullable, generated or has a default (the generated DAO defaults
63
+ // all of those); a custom DTO field only when it has a `default` — set
64
+ // `optional: true` for a Kotlin default the metadata can't show, such as
65
+ // `val enabled: Boolean? = true`.
66
+ optional?: boolean;
67
+ }
68
+
69
+ // A field the database fills on insert. Declares the shape only: metadata
70
+ // marks it NULLABLE:false, OPTIONAL:true and leaves it out of `required`.
71
+ // Value generation still comes from `sequencePrefix` (single-field keys).
72
+ // A table with `sequencePrefix` gets its key marked as a SEQUENCE without
73
+ // declaring it here.
74
+ export interface GeneratedFieldDef {
75
+ field: string;
76
+ kind: 'SEQUENCE' | 'AUTO_INCREMENT';
77
+ prefix?: string;
78
+ }
79
+
80
+ // A table or dataserver-query index (`indices { unique("A") }`). FIELDS is
81
+ // sent space-separated (`'A B'`).
82
+ export interface IndexDef {
83
+ name: string;
84
+ fields: string[];
85
+ unique?: boolean;
86
+ }
87
+
17
88
  export interface TableDef {
18
89
  // A single field, or several for composite-keyed tables (e.g. PRICE keyed
19
90
  // by [RFQ_ID, SIDE]). Composite rows are identified by the fields' values
@@ -23,9 +94,28 @@ export interface TableDef {
23
94
  pkField: string | string[];
24
95
  sequencePrefix?: string;
25
96
  sequenceWidth?: number;
97
+ // How generated pk values look. See SequenceFormat.
98
+ sequenceFormat?: SequenceFormat;
26
99
  rows?: Row[];
100
+ // Declared columns, in dictionary order. When present they are the schema:
101
+ // metadata, JSON schemas and dataserver rows follow them (missing values go
102
+ // out as null), even with no rows at all. When absent, field metadata is
103
+ // inferred from the rows (src/db/metadata.ts).
104
+ fields?: FieldDef[];
105
+ generated?: GeneratedFieldDef[];
106
+ // Table indices. Declared for later items (ORDER_BY, duplicate keys); a
107
+ // dataserver query only reports the indices on its own QueryDef.indexes, as
108
+ // the real dataserver does.
109
+ indexes?: IndexDef[];
27
110
  }
28
111
 
112
+ // 'prefix' (the default): sequencePrefix + a counter padded to sequenceWidth
113
+ // ('TR001'). 'genesis': the platform's id format (genesis-db IdStrategy.java)
114
+ // — the counter padded to 15 digits, the sequence id (sequencePrefix, two
115
+ // characters in a GSF dictionary), then location 'LO' and system '1':
116
+ // '000000000000001TRLO1'.
117
+ export type SequenceFormat = 'prefix' | 'genesis';
118
+
29
119
  export interface JoinDef {
30
120
  table: string;
31
121
  on: [string, string];
@@ -53,6 +143,16 @@ export interface ViewDef {
53
143
  joins?: JoinDef[];
54
144
  antiJoins?: AntiJoinDef[];
55
145
  derived?: Record<string, (row: Row) => any>;
146
+ // Types of the `derived` fields (`derivedField("TOTAL", DOUBLE)`): a type,
147
+ // or a full field definition without its name. Columns from the base table
148
+ // and from `joins[].select` take their declared types from their source
149
+ // tables; a derived field with no entry here is inferred from the rows.
150
+ derivedTypes?: Record<string, GenesisFieldType | Omit<FieldDef, 'name'>>;
151
+ // The view's fields { } block: the only columns it exposes, in this order
152
+ // (base, joined and derived alike). Unset: every base column, then each
153
+ // join's select, then the derived fields. Keep the key fields in it — they
154
+ // are the ROW_REF.
155
+ fields?: string[];
56
156
  }
57
157
 
58
158
  // The authenticated identity a resource-level auth hook sees. userName is
@@ -76,7 +176,17 @@ export interface ResourceAuthDef {
76
176
 
77
177
  export interface QueryDef {
78
178
  source: string;
179
+ // Overrides the TYPE of named fields, declared or inferred.
79
180
  fieldTypes?: Record<string, string>;
181
+ // The query's `fields { }` block: the only columns it exposes, in this
182
+ // order, in rows and in META_REQUEST. Names the source doesn't have are
183
+ // dropped from metadata. RECORD_ID and TIMESTAMP reach dataserver rows only
184
+ // when named here.
185
+
186
+ fields?: string[];
187
+ // The query's `indices { }` block, reported as META_REQUEST INDEXES. With
188
+ // none, INDEXES is the default [{NAME: null, FIELDS: 'RECORD_ID'}].
189
+ indexes?: IndexDef[];
80
190
  // Server-side where-clause equivalent, applied before any client
81
191
  // CRITERIA_MATCH — to both the DATA_LOGON snapshot and live pushes. Use it
82
192
  // for things the real query's filter/where clause does (e.g. keep template
@@ -110,12 +220,35 @@ export interface RequestReplyDef {
110
220
  fieldTypes?: Record<string, string>;
111
221
  filter?: (row: Row) => boolean;
112
222
  auth?: ResourceAuthDef;
223
+ // `criteriaOnlyRequest = true` in the request server's config: META_REQUEST
224
+ // then reports REQUEST_FIELD: [], CRITERIA_ONLY_REQUEST: true,
225
+ // SORTABLE_FIELDS and CRITERIA_FIELDS, and adds RECORD_ID and TIMESTAMP to
226
+ // REPLY_FIELD. Metadata only for now: OFFSET paging is not implemented, and
227
+ // grid-pro's server-side and infinite row models page a criteria-only
228
+ // resource with OFFSET.
229
+ criteriaOnly?: boolean;
230
+ // The request block's fields (REQUEST_FIELD, and the JSON schema's REQUEST).
231
+ // Defaults to the source's primary key, as for a GPAL requestReply(TABLE).
232
+ requestFields?: string[];
113
233
  }
114
234
 
235
+ // How META_REQUEST / JSON_SCHEMA_REQUEST describe an event whose name doesn't
236
+ // follow EVENT_<TABLE>_<VERB>: the table (or view) its DETAILS take, and
237
+ // whether only the key fields are sent (a delete).
115
238
  export interface EventSchemaOverride {
116
239
  table?: string;
117
240
  pkOnly?: boolean;
118
241
  fieldTypes?: Record<string, string>;
242
+ // The event's own DETAILS class, for an event that takes a custom DTO
243
+ // (`eventHandler<MyInput>`) rather than a table row. Described as the real
244
+ // server describes a non-table class: no titles, lengths, ranges or
245
+ // genesis types; `nullable`, `default` and `optional` as declared. A
246
+ // nullable field is still mandatory unless it has a default or
247
+ // `optional: true` (Kotlin `val x: T?` must be sent; `val x: T? = null`
248
+ // needn't be). `table` then only names where its ENUM columns come from.
249
+ fields?: FieldDef[];
250
+ // The DTO's class name, reported as the event's DESCRIPTION.
251
+ className?: string;
119
252
  }
120
253
 
121
254
  export interface ResourceItem {
@@ -181,7 +314,11 @@ export interface EventHandlerCtx {
181
314
  broadcastTableChange: BroadcastTableChangeFn;
182
315
  }
183
316
 
184
- export type EventHandlerResult = { generated?: Row[] } | void;
317
+ // `warnings` turns the reply into an EVENT_NACK carrying them in WARNING
318
+ // (with an empty ERROR), as a GSF handler's warning-only EventNack does.
319
+ // Return them from a handler that has not written anything yet: the mock has
320
+ // no transaction to roll back, and doesn't honour IGNORE_WARNINGS yet.
321
+ export type EventHandlerResult = { generated?: Row[]; warnings?: NackErrorInput[] } | void;
185
322
 
186
323
  export type EventHandler = (details: Row, ctx: EventHandlerCtx) => EventHandlerResult;
187
324
 
@@ -245,11 +382,18 @@ export interface MockServerConfig {
245
382
  httpRoutes?: HttpRouteDef[];
246
383
  onMessage?: OnMessageHook;
247
384
  productName?: string;
385
+ // The Genesis application name, as in the generated enum classes the real
386
+ // server names in event metadata
387
+ // (global.genesis.gen.dao.enums.<appName>.<table>.<Field>). Unset: the
388
+ // segment is left out. Nothing a client does depends on it.
389
+ appName?: string;
248
390
  }
249
391
 
392
+ // A META_REQUEST FIELD / REQUEST_FIELD / REPLY_FIELD entry.
250
393
  export interface FieldMetadata {
251
394
  NAME: string;
252
395
  TYPE: string;
396
+ VALID_VALUES?: string[];
253
397
  }
254
398
 
255
399
  // Minimal shape both `ws`'s WebSocket and Bun's ServerWebSocket satisfy —