@shipload/sdk 2.0.0-rc2 → 2.0.0-rc20

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 (80) hide show
  1. package/README.md +1 -349
  2. package/lib/shipload.d.ts +1658 -1126
  3. package/lib/shipload.js +6847 -3082
  4. package/lib/shipload.js.map +1 -1
  5. package/lib/shipload.m.js +6468 -2793
  6. package/lib/shipload.m.js.map +1 -1
  7. package/package.json +6 -4
  8. package/src/capabilities/crafting.ts +22 -0
  9. package/src/capabilities/gathering.ts +36 -0
  10. package/src/capabilities/guards.ts +3 -8
  11. package/src/capabilities/hauling.ts +22 -0
  12. package/src/capabilities/index.ts +4 -1
  13. package/src/capabilities/modules.ts +57 -0
  14. package/src/capabilities/storage.ts +101 -9
  15. package/src/contracts/server.ts +717 -293
  16. package/src/data/capabilities.ts +408 -0
  17. package/src/data/categories.ts +55 -0
  18. package/src/data/colors.ts +71 -0
  19. package/src/data/items.json +17 -0
  20. package/src/data/locations.ts +53 -0
  21. package/src/data/nebula-adjectives.json +211 -0
  22. package/src/data/nebula-nouns.json +151 -0
  23. package/src/data/recipes.ts +587 -0
  24. package/src/data/syllables.json +1386 -780
  25. package/src/data/tiers.ts +45 -0
  26. package/src/derivation/crafting.ts +287 -0
  27. package/src/derivation/index.ts +30 -0
  28. package/src/derivation/location-size.ts +15 -0
  29. package/src/derivation/resources.ts +136 -0
  30. package/src/derivation/stats.ts +146 -0
  31. package/src/derivation/stratum.ts +134 -0
  32. package/src/derivation/tiers.ts +54 -0
  33. package/src/entities/cargo-utils.ts +10 -68
  34. package/src/entities/container.ts +37 -0
  35. package/src/entities/entity-inventory.ts +13 -13
  36. package/src/entities/inventory-accessor.ts +2 -6
  37. package/src/entities/location.ts +5 -200
  38. package/src/entities/makers.ts +136 -17
  39. package/src/entities/player.ts +1 -274
  40. package/src/entities/ship-deploy.ts +258 -0
  41. package/src/entities/ship.ts +28 -34
  42. package/src/entities/warehouse.ts +35 -7
  43. package/src/errors.ts +59 -5
  44. package/src/format.ts +12 -0
  45. package/src/index-module.ts +233 -50
  46. package/src/managers/actions.ts +138 -88
  47. package/src/managers/context.ts +19 -9
  48. package/src/managers/index.ts +0 -1
  49. package/src/managers/locations.ts +2 -85
  50. package/src/market/items.ts +93 -0
  51. package/src/nft/description.ts +176 -0
  52. package/src/nft/deserializers.ts +81 -0
  53. package/src/nft/index.ts +2 -0
  54. package/src/resolution/describe-module.ts +165 -0
  55. package/src/resolution/display-name.ts +39 -0
  56. package/src/resolution/resolve-item.ts +343 -0
  57. package/src/scheduling/projection.ts +220 -67
  58. package/src/scheduling/schedule.ts +2 -2
  59. package/src/shipload.ts +10 -5
  60. package/src/subscriptions/connection.ts +154 -0
  61. package/src/subscriptions/debug.ts +17 -0
  62. package/src/subscriptions/index.ts +5 -0
  63. package/src/subscriptions/manager.ts +240 -0
  64. package/src/subscriptions/mappers.ts +28 -0
  65. package/src/subscriptions/types.ts +143 -0
  66. package/src/travel/travel.ts +30 -17
  67. package/src/types/capabilities.ts +11 -14
  68. package/src/types/entity-traits.ts +3 -4
  69. package/src/types/entity.ts +9 -6
  70. package/src/types.ts +61 -55
  71. package/src/utils/system.ts +66 -53
  72. package/src/capabilities/extraction.ts +0 -37
  73. package/src/data/goods.json +0 -23
  74. package/src/managers/trades.ts +0 -119
  75. package/src/market/goods.ts +0 -31
  76. package/src/market/market.ts +0 -208
  77. package/src/market/rolls.ts +0 -8
  78. package/src/trading/collect.ts +0 -938
  79. package/src/trading/deal.ts +0 -207
  80. package/src/trading/trade.ts +0 -203
@@ -8,28 +8,50 @@ import {
8
8
  EntityCapabilities,
9
9
  EntityState,
10
10
  } from '../types/capabilities'
11
+ import {
12
+ ENTITY_CAPACITY_EXCEEDED,
13
+ RECIPE_INPUTS_EXCESS,
14
+ RECIPE_INPUTS_INSUFFICIENT,
15
+ RECIPE_INPUTS_INVALID,
16
+ RECIPE_INPUTS_MIXED,
17
+ RECIPE_NOT_FOUND,
18
+ SHIP_CARGO_NOT_LOADED,
19
+ } from '../errors'
20
+ import {
21
+ getComponentById,
22
+ getEntityRecipeByItemId,
23
+ getModuleRecipeByItemId,
24
+ RecipeInput,
25
+ } from '../data/recipes'
26
+ import {getItem} from '../market/items'
11
27
  import {distanceBetweenCoordinates, lerp} from '../travel/travel'
12
- import {calcCargoMass} from '../capabilities/storage'
13
- import {getGood} from '../market/goods'
28
+ import {
29
+ calcStacksMass,
30
+ cargoItemToStack,
31
+ CargoStack,
32
+ mergeStacks,
33
+ removeFromStacks,
34
+ stackToCargoItem,
35
+ } from '../capabilities/storage'
14
36
  import * as schedule from './schedule'
15
37
  import {ScheduleData} from './schedule'
16
38
 
17
39
  export interface ProjectedEntity {
18
40
  location: Coordinates
19
41
  energy: UInt16
20
- cargoMass: UInt64
42
+ cargo: CargoStack[]
21
43
  shipMass: UInt32
22
44
  capacity?: UInt64
23
45
  engines?: ServerContract.Types.movement_stats
24
46
  loaders?: ServerContract.Types.loader_stats
25
47
  generator?: ServerContract.Types.energy_stats
26
- trade?: ServerContract.Types.trade_stats
48
+ hauler?: ServerContract.Types.hauler_stats
49
+ readonly cargoMass: UInt64
27
50
  readonly totalMass: UInt64
28
51
 
29
52
  hasMovement(): boolean
30
53
  hasStorage(): boolean
31
54
  hasLoaders(): boolean
32
- hasTrade(): boolean
33
55
 
34
56
  capabilities(): EntityCapabilities
35
57
  state(): EntityState
@@ -42,7 +64,7 @@ export interface Projectable extends ScheduleData {
42
64
  generator?: ServerContract.Types.energy_stats
43
65
  engines?: ServerContract.Types.movement_stats
44
66
  loaders?: ServerContract.Types.loader_stats
45
- trade?: ServerContract.Types.trade_stats
67
+ hauler?: ServerContract.Types.hauler_stats
46
68
  capacity?: UInt32
47
69
  cargo: ServerContract.Types.cargo_item[]
48
70
  cargomass: UInt32
@@ -54,24 +76,29 @@ function getHullMass(entity: Projectable): UInt32 {
54
76
  }
55
77
 
56
78
  export function createProjectedEntity(entity: Projectable): ProjectedEntity {
57
- const cargoMass = calcCargoMass(entity)
58
79
  const shipMass = getHullMass(entity)
59
80
  const loaders = entity.loaders
60
81
  const engines = entity.engines
61
82
  const generator = entity.generator
62
- const trade = entity.trade
83
+ const hauler = entity.hauler
63
84
  const capacity = entity.capacity
64
85
 
86
+ const cargo: CargoStack[] = entity.cargo.map(cargoItemToStack)
87
+
65
88
  const projected: ProjectedEntity = {
66
89
  location: Coordinates.from(entity.coordinates),
67
90
  energy: UInt16.from(entity.energy ?? 0),
68
- cargoMass,
91
+ cargo,
69
92
  shipMass,
70
93
  capacity: capacity ? UInt64.from(capacity) : undefined,
71
94
  engines,
72
95
  generator,
96
+ hauler,
73
97
  loaders,
74
- trade,
98
+
99
+ get cargoMass() {
100
+ return calcStacksMass(this.cargo)
101
+ },
75
102
 
76
103
  get totalMass() {
77
104
  let mass = UInt64.from(this.shipMass).adding(this.cargoMass)
@@ -93,10 +120,6 @@ export function createProjectedEntity(entity: Projectable): ProjectedEntity {
93
120
  return capsHasLoaders(this.capabilities())
94
121
  },
95
122
 
96
- hasTrade() {
97
- return this.trade !== undefined
98
- },
99
-
100
123
  capabilities(): EntityCapabilities {
101
124
  return {
102
125
  hullmass: this.shipMass,
@@ -104,7 +127,6 @@ export function createProjectedEntity(entity: Projectable): ProjectedEntity {
104
127
  engines: this.engines,
105
128
  generator: this.generator,
106
129
  loaders: this.loaders,
107
- trade: this.trade,
108
130
  }
109
131
  },
110
132
 
@@ -114,7 +136,7 @@ export function createProjectedEntity(entity: Projectable): ProjectedEntity {
114
136
  location: ServerContract.Types.coordinates.from(this.location),
115
137
  energy: this.energy,
116
138
  cargomass: UInt32.from(this.cargoMass),
117
- cargo: entity.cargo,
139
+ cargo: this.cargo.map(stackToCargoItem),
118
140
  }
119
141
  },
120
142
  }
@@ -169,76 +191,205 @@ function applyFlightTask(
169
191
  }
170
192
  }
171
193
 
172
- function getGoodMass(good_id: UInt16 | number): UInt32 {
173
- const good = getGood(good_id)
174
- return good.mass
194
+ function addCargoItem(projected: ProjectedEntity, item: ServerContract.Types.cargo_item): void {
195
+ projected.cargo = mergeStacks(projected.cargo, cargoItemToStack(item))
196
+ }
197
+
198
+ function removeCargoItem(projected: ProjectedEntity, item: ServerContract.Types.cargo_item): void {
199
+ projected.cargo = removeFromStacks(projected.cargo, cargoItemToStack(item))
175
200
  }
176
201
 
177
- function applyLoadTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
202
+ function applyAddCargoTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
178
203
  for (const item of task.cargo) {
179
- const good_mass = getGoodMass(item.good_id)
180
- projected.cargoMass = projected.cargoMass.adding(good_mass.multiplying(item.quantity))
204
+ addCargoItem(projected, item)
181
205
  }
182
206
  }
183
207
 
184
- function applyUnloadTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
208
+ function applyRemoveCargoTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
185
209
  for (const item of task.cargo) {
186
- const good_mass = getGoodMass(item.good_id)
187
- const cargoMass = good_mass.multiplying(item.quantity)
188
- projected.cargoMass = projected.cargoMass.gt(cargoMass)
189
- ? projected.cargoMass.subtracting(cargoMass)
190
- : UInt64.from(0)
210
+ removeCargoItem(projected, item)
191
211
  }
192
212
  }
193
213
 
194
- function applyExtractTask(
214
+ function applyEnergyCost(projected: ProjectedEntity, task: ServerContract.Types.task): void {
215
+ if (!task.energy_cost) return
216
+ const energyCost = UInt16.from(task.energy_cost)
217
+ projected.energy = projected.energy.gt(energyCost)
218
+ ? UInt16.from(projected.energy.subtracting(energyCost))
219
+ : UInt16.from(0)
220
+ }
221
+
222
+ function applyGatherTask(
195
223
  projected: ProjectedEntity,
196
224
  task: ServerContract.Types.task,
197
225
  options: {complete: boolean}
198
226
  ): void {
199
227
  if (!options.complete) return
228
+ applyEnergyCost(projected, task)
229
+ if (!task.entitytarget) {
230
+ applyAddCargoTask(projected, task)
231
+ }
232
+ }
200
233
 
201
- if (task.energy_cost) {
202
- const energyCost = UInt16.from(task.energy_cost)
203
- projected.energy = projected.energy.gt(energyCost)
204
- ? UInt16.from(projected.energy.subtracting(energyCost))
205
- : UInt16.from(0)
234
+ function applyCraftTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
235
+ applyEnergyCost(projected, task)
236
+ if (task.cargo.length === 0) return
237
+
238
+ for (let i = 0; i < task.cargo.length - 1; i++) {
239
+ removeCargoItem(projected, task.cargo[i])
206
240
  }
241
+ addCargoItem(projected, task.cargo[task.cargo.length - 1])
242
+ }
207
243
 
208
- for (const item of task.cargo) {
209
- const good_mass = getGoodMass(item.good_id)
210
- projected.cargoMass = projected.cargoMass.adding(good_mass.multiplying(item.quantity))
244
+ function applyDeployTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
245
+ applyEnergyCost(projected, task)
246
+ if (task.cargo.length > 0) {
247
+ removeCargoItem(projected, task.cargo[0])
211
248
  }
212
249
  }
213
250
 
214
- export function projectEntity(entity: Projectable): ProjectedEntity {
251
+ function applyTask(projected: ProjectedEntity, task: ServerContract.Types.task): void {
252
+ switch (task.type.toNumber()) {
253
+ case TaskType.RECHARGE:
254
+ applyRechargeTask(projected, task, {complete: true})
255
+ break
256
+ case TaskType.TRAVEL:
257
+ applyFlightTask(projected, task, {complete: true})
258
+ break
259
+ case TaskType.LOAD:
260
+ case TaskType.UNWRAP:
261
+ applyAddCargoTask(projected, task)
262
+ break
263
+ case TaskType.UNLOAD:
264
+ case TaskType.WRAP:
265
+ applyRemoveCargoTask(projected, task)
266
+ break
267
+ case TaskType.GATHER:
268
+ applyGatherTask(projected, task, {complete: true})
269
+ break
270
+ case TaskType.CRAFT:
271
+ applyCraftTask(projected, task)
272
+ break
273
+ case TaskType.DEPLOY:
274
+ applyDeployTask(projected, task)
275
+ break
276
+ }
277
+ }
278
+
279
+ export interface ProjectionOptions {
280
+ upToTaskIndex?: number
281
+ }
282
+
283
+ export function projectEntity(entity: Projectable, options?: ProjectionOptions): ProjectedEntity {
215
284
  const projected = createProjectedEntity(entity)
285
+ if (!entity.schedule || entity.schedule.tasks.length === 0) return projected
216
286
 
217
- if (!entity.schedule) {
218
- return projected
287
+ const tasks = entity.schedule.tasks
288
+ const taskCount =
289
+ options?.upToTaskIndex !== undefined
290
+ ? Math.max(0, Math.min(options.upToTaskIndex, tasks.length))
291
+ : tasks.length
292
+
293
+ for (let i = 0; i < taskCount; i++) {
294
+ applyTask(projected, tasks[i])
219
295
  }
296
+ return projected
297
+ }
220
298
 
221
- for (const task of entity.schedule.tasks) {
222
- switch (task.type.toNumber()) {
223
- case TaskType.RECHARGE:
224
- applyRechargeTask(projected, task, {complete: true})
225
- break
226
- case TaskType.TRAVEL:
227
- applyFlightTask(projected, task, {complete: true})
228
- break
229
- case TaskType.LOAD:
230
- applyLoadTask(projected, task)
231
- break
232
- case TaskType.UNLOAD:
233
- applyUnloadTask(projected, task)
234
- break
235
- case TaskType.EXTRACT:
236
- applyExtractTask(projected, task, {complete: true})
299
+ function getRecipeForOutput(outputItemId: number): RecipeInput[] | undefined {
300
+ const component = getComponentById(outputItemId)
301
+ if (component) return component.recipe
302
+ const moduleRecipe = getModuleRecipeByItemId(outputItemId)
303
+ if (moduleRecipe) return moduleRecipe.recipe
304
+ const entityRecipe = getEntityRecipeByItemId(outputItemId)
305
+ if (entityRecipe) return entityRecipe.recipe
306
+ return undefined
307
+ }
308
+
309
+ function validateCraftTask(task: ServerContract.Types.task, projected: ProjectedEntity): void {
310
+ if (task.cargo.length === 0) return
311
+
312
+ const output = task.cargo[task.cargo.length - 1]
313
+ const inputs = task.cargo.slice(0, -1)
314
+ const craftQuantity = output.quantity.toNumber()
315
+
316
+ const recipe = getRecipeForOutput(output.item_id.toNumber())
317
+ if (!recipe) throw new Error(RECIPE_NOT_FOUND)
318
+
319
+ const groupedInputs: ServerContract.Types.cargo_item[][] = recipe.map(() => [])
320
+ for (const input of inputs) {
321
+ let matched = false
322
+ for (let ri = 0; ri < recipe.length; ri++) {
323
+ const req = recipe[ri]
324
+ if (req.itemId && req.itemId > 0) {
325
+ if (input.item_id.toNumber() === req.itemId) {
326
+ groupedInputs[ri].push(input)
327
+ matched = true
328
+ break
329
+ }
330
+ } else if (req.category) {
331
+ const item = getItem(input.item_id)
332
+ if (item.category === req.category) {
333
+ groupedInputs[ri].push(input)
334
+ matched = true
335
+ break
336
+ }
337
+ }
338
+ }
339
+ if (!matched) throw new Error(RECIPE_INPUTS_INVALID)
340
+ }
341
+
342
+ for (let ri = 0; ri < recipe.length; ri++) {
343
+ const stacks = groupedInputs[ri]
344
+ let provided = 0
345
+ for (const stack of stacks) {
346
+ provided += stack.quantity.toNumber()
347
+ }
348
+ const required = recipe[ri].quantity * craftQuantity
349
+ if (provided < required) throw new Error(RECIPE_INPUTS_INSUFFICIENT)
350
+ if (provided !== required) throw new Error(RECIPE_INPUTS_EXCESS)
351
+
352
+ if (!recipe[ri].itemId && stacks.length > 1) {
353
+ const firstItemId = stacks[0].item_id.toNumber()
354
+ for (let si = 1; si < stacks.length; si++) {
355
+ if (stacks[si].item_id.toNumber() !== firstItemId) {
356
+ throw new Error(RECIPE_INPUTS_MIXED)
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ for (const input of inputs) {
363
+ let found = false
364
+ for (const pc of projected.cargo) {
365
+ if (
366
+ pc.item_id.toNumber() === input.item_id.toNumber() &&
367
+ pc.stats.toString() === input.stats.toString()
368
+ ) {
369
+ if (pc.quantity.toNumber() < input.quantity.toNumber()) {
370
+ throw new Error(RECIPE_INPUTS_INSUFFICIENT)
371
+ }
372
+ found = true
237
373
  break
374
+ }
238
375
  }
376
+ if (!found) throw new Error(SHIP_CARGO_NOT_LOADED)
239
377
  }
378
+ }
240
379
 
241
- return projected
380
+ export function validateSchedule(entity: Projectable): void {
381
+ if (!entity.schedule || entity.schedule.tasks.length === 0) return
382
+
383
+ const projected = createProjectedEntity(entity)
384
+ for (const task of entity.schedule.tasks) {
385
+ if (task.type.toNumber() === TaskType.CRAFT) {
386
+ validateCraftTask(task, projected)
387
+ }
388
+ applyTask(projected, task)
389
+ if (projected.capacity && projected.cargoMass.gt(projected.capacity)) {
390
+ throw new Error(ENTITY_CAPACITY_EXCEEDED)
391
+ }
392
+ }
242
393
  }
243
394
 
244
395
  export function projectEntityAt(entity: Projectable, now: Date): ProjectedEntity {
@@ -269,19 +420,21 @@ export function projectEntityAt(entity: Projectable, now: Date): ProjectedEntity
269
420
  applyFlightTask(projected, task, {complete: taskComplete, progress})
270
421
  break
271
422
  case TaskType.LOAD:
272
- if (taskComplete) {
273
- applyLoadTask(projected, task)
274
- }
423
+ case TaskType.UNWRAP:
424
+ if (taskComplete) applyAddCargoTask(projected, task)
275
425
  break
276
426
  case TaskType.UNLOAD:
277
- if (taskComplete) {
278
- applyUnloadTask(projected, task)
279
- }
427
+ case TaskType.WRAP:
428
+ if (taskComplete) applyRemoveCargoTask(projected, task)
280
429
  break
281
- case TaskType.EXTRACT:
282
- if (taskComplete) {
283
- applyExtractTask(projected, task, {complete: true})
284
- }
430
+ case TaskType.GATHER:
431
+ if (taskComplete) applyGatherTask(projected, task, {complete: true})
432
+ break
433
+ case TaskType.CRAFT:
434
+ if (taskComplete) applyCraftTask(projected, task)
435
+ break
436
+ case TaskType.DEPLOY:
437
+ if (taskComplete) applyDeployTask(projected, task)
285
438
  break
286
439
  }
287
440
  }
@@ -174,6 +174,6 @@ export function isUnloading(entity: ScheduleData, now: Date): boolean {
174
174
  return isTaskType(entity, TaskType.UNLOAD, now)
175
175
  }
176
176
 
177
- export function isExtracting(entity: ScheduleData, now: Date): boolean {
178
- return isTaskType(entity, TaskType.EXTRACT, now)
177
+ export function isGathering(entity: ScheduleData, now: Date): boolean {
178
+ return isTaskType(entity, TaskType.GATHER, now)
179
179
  }
package/src/shipload.ts CHANGED
@@ -7,15 +7,16 @@ import {GameContext} from './managers/context'
7
7
  import {EntitiesManager} from './managers/entities'
8
8
  import {PlayersManager} from './managers/players'
9
9
  import {LocationsManager} from './managers/locations'
10
- import {TradesManager} from './managers/trades'
11
10
  import {EpochsManager} from './managers/epochs'
12
11
  import {ActionsManager} from './managers/actions'
12
+ import {SubscriptionsManager} from './subscriptions/manager'
13
13
  import {GameState} from './entities/gamestate'
14
14
 
15
15
  interface ShiploadOptions {
16
16
  platformContractName?: string
17
17
  serverContractName?: string
18
18
  client?: APIClient
19
+ subscriptionsUrl?: string
19
20
  }
20
21
 
21
22
  interface ShiploadConstructorOptions extends ShiploadOptions {
@@ -39,6 +40,10 @@ export class Shipload {
39
40
  : new ServerContract.Contract({client: apiClient})
40
41
 
41
42
  this._context = new GameContext(apiClient, server, platform)
43
+
44
+ if (constructorOptions?.subscriptionsUrl) {
45
+ this._context.setSubscriptionsUrl(constructorOptions.subscriptionsUrl)
46
+ }
42
47
  }
43
48
 
44
49
  static async load(
@@ -94,10 +99,6 @@ export class Shipload {
94
99
  return this._context.locations
95
100
  }
96
101
 
97
- get trades(): TradesManager {
98
- return this._context.trades
99
- }
100
-
101
102
  get epochs(): EpochsManager {
102
103
  return this._context.epochs
103
104
  }
@@ -106,6 +107,10 @@ export class Shipload {
106
107
  return this._context.actions
107
108
  }
108
109
 
110
+ get subscriptions(): SubscriptionsManager {
111
+ return this._context.subscriptions
112
+ }
113
+
109
114
  async getGame(reload = false): Promise<PlatformContract.Types.game_row> {
110
115
  return this._context.getGame(reload)
111
116
  }
@@ -0,0 +1,154 @@
1
+ import type {ClientMessage, ServerMessage} from './types'
2
+ import {debug} from './debug'
3
+
4
+ export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
5
+
6
+ export interface WebSocketConnectionOptions {
7
+ url: string
8
+ onMessage: (message: ServerMessage) => void
9
+ onStateChange?: (state: ConnectionState) => void
10
+ }
11
+
12
+ export class WebSocketConnection {
13
+ private ws: WebSocket | null = null
14
+ private url: string
15
+ private onMessage: (message: ServerMessage) => void
16
+ private onStateChange?: (state: ConnectionState) => void
17
+ private reconnectAttempts = 0
18
+ private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
19
+ private _state: ConnectionState = 'disconnected'
20
+ private shouldReconnect = true
21
+ private sendQueue: string[] = []
22
+
23
+ private static readonly MIN_RECONNECT_DELAY = 1000
24
+ private static readonly MAX_RECONNECT_DELAY = 30000
25
+ private static readonly RECONNECT_MULTIPLIER = 2
26
+
27
+ constructor(options: WebSocketConnectionOptions) {
28
+ this.url = options.url
29
+ this.onMessage = options.onMessage
30
+ this.onStateChange = options.onStateChange
31
+ }
32
+
33
+ get state(): ConnectionState {
34
+ return this._state
35
+ }
36
+
37
+ private setState(state: ConnectionState) {
38
+ if (this._state !== state) {
39
+ this._state = state
40
+ this.onStateChange?.(state)
41
+ }
42
+ }
43
+
44
+ connect() {
45
+ if (this.ws) {
46
+ return
47
+ }
48
+
49
+ this.shouldReconnect = true
50
+ this.setState('connecting')
51
+ debug('Connecting to', this.url)
52
+
53
+ try {
54
+ this.ws = new WebSocket(this.url)
55
+
56
+ this.ws.onopen = () => {
57
+ debug('Connected')
58
+ this.reconnectAttempts = 0
59
+ this.setState('connected')
60
+ while (
61
+ this.sendQueue.length > 0 &&
62
+ this.ws &&
63
+ this.ws.readyState === WebSocket.OPEN
64
+ ) {
65
+ this.ws.send(this.sendQueue.shift()!)
66
+ }
67
+ }
68
+
69
+ this.ws.onmessage = (event) => {
70
+ try {
71
+ const message = JSON.parse(event.data) as ServerMessage
72
+ this.onMessage(message)
73
+ } catch (e) {
74
+ // eslint-disable-next-line no-console
75
+ console.error('[WS] Failed to parse message:', e)
76
+ }
77
+ }
78
+
79
+ this.ws.onclose = () => {
80
+ this.ws = null
81
+ this.sendQueue.length = 0
82
+
83
+ if (this.shouldReconnect) {
84
+ this.setState('reconnecting')
85
+ this.scheduleReconnect()
86
+ } else {
87
+ this.setState('disconnected')
88
+ }
89
+ }
90
+ } catch (e) {
91
+ // eslint-disable-next-line no-console
92
+ console.error('[WS] Failed to create connection:', e)
93
+ this.ws = null
94
+ if (this.shouldReconnect) {
95
+ this.setState('reconnecting')
96
+ this.scheduleReconnect()
97
+ }
98
+ }
99
+ }
100
+
101
+ private scheduleReconnect() {
102
+ if (this.reconnectTimeout) {
103
+ return
104
+ }
105
+
106
+ const delay = Math.min(
107
+ WebSocketConnection.MIN_RECONNECT_DELAY *
108
+ Math.pow(WebSocketConnection.RECONNECT_MULTIPLIER, this.reconnectAttempts),
109
+ WebSocketConnection.MAX_RECONNECT_DELAY
110
+ )
111
+
112
+ debug(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`)
113
+
114
+ this.reconnectTimeout = setTimeout(() => {
115
+ this.reconnectTimeout = null
116
+ this.reconnectAttempts++
117
+ this.connect()
118
+ }, delay)
119
+ }
120
+
121
+ disconnect() {
122
+ this.shouldReconnect = false
123
+
124
+ if (this.reconnectTimeout) {
125
+ clearTimeout(this.reconnectTimeout)
126
+ this.reconnectTimeout = null
127
+ }
128
+
129
+ if (this.ws) {
130
+ this.ws.close()
131
+ this.ws = null
132
+ }
133
+
134
+ this.sendQueue.length = 0
135
+ this.setState('disconnected')
136
+ }
137
+
138
+ close() {
139
+ this.disconnect()
140
+ }
141
+
142
+ send(message: ClientMessage) {
143
+ const data = JSON.stringify(message)
144
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
145
+ this.ws.send(data)
146
+ return
147
+ }
148
+ this.sendQueue.push(data)
149
+ }
150
+
151
+ get isConnected(): boolean {
152
+ return this._state === 'connected'
153
+ }
154
+ }
@@ -0,0 +1,17 @@
1
+ /* eslint-disable no-console */
2
+
3
+ let enabled = false
4
+
5
+ export function setSubscriptionsDebug(on: boolean): void {
6
+ enabled = on
7
+ }
8
+
9
+ export function isSubscriptionsDebugEnabled(): boolean {
10
+ return enabled
11
+ }
12
+
13
+ export function debug(...args: unknown[]): void {
14
+ if (enabled) {
15
+ console.log('[WS]', ...args)
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ export * from './connection'
2
+ export * from './types'
3
+ export * from './manager'
4
+ export * from './mappers'
5
+ export {setSubscriptionsDebug, isSubscriptionsDebugEnabled} from './debug'