@gama-platform/gama-client 1.0.2

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.
@@ -0,0 +1,532 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defProps = Object.defineProperties;
3
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
4
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
7
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
8
+ var __spreadValues = (a, b) => {
9
+ for (var prop in b || (b = {}))
10
+ if (__hasOwnProp.call(b, prop))
11
+ __defNormalProp(a, prop, b[prop]);
12
+ if (__getOwnPropSymbols)
13
+ for (var prop of __getOwnPropSymbols(b)) {
14
+ if (__propIsEnum.call(b, prop))
15
+ __defNormalProp(a, prop, b[prop]);
16
+ }
17
+ return a;
18
+ };
19
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
20
+
21
+ // src/gama_client.ts
22
+ import WebSocket from "ws";
23
+
24
+ // src/constants.ts
25
+ var GAMA_ERROR_MESSAGES = [
26
+ "SimulationStatusError",
27
+ "SimulationErrorDialog",
28
+ "SimulationError",
29
+ "RuntimeError",
30
+ "GamaServerError",
31
+ "MalformedRequest",
32
+ "UnableToExecuteRequest"
33
+ ];
34
+
35
+ // src/gama_client.ts
36
+ import { getLogger } from "@logtape/logtape";
37
+ var logger = getLogger(["GAMA-library", "GAMA-client"]);
38
+ var GamaClient = class {
39
+ //websocket of the client. needs to be initialized by using the asynchronous connectGama() to be used
40
+ /**
41
+ *
42
+ * @param port port of gama server you want to reach
43
+ * @param host host of the gama server you want to reach
44
+ */
45
+ constructor(port, host) {
46
+ // json object detailing the state of the gama server, including: if connected, experiment details, errors from the server, and loading status
47
+ this.port = 1e3;
48
+ //default port number pointing to the gama server. can be redefined when using the constructor
49
+ this.host = "localhost";
50
+ this.jsonGamaState = {
51
+ connected: false,
52
+ model_path: "",
53
+ experiment_state: "NONE",
54
+ loading: false,
55
+ content_error: "",
56
+ experiment_id: "",
57
+ experiment_name: ""
58
+ };
59
+ this.port = port || 1e3;
60
+ this.host = host || "localhost";
61
+ }
62
+ //? GETTERS
63
+ isConnected() {
64
+ return this.jsonGamaState.connected;
65
+ }
66
+ getExperimentState() {
67
+ return this.jsonGamaState.experiment_state;
68
+ }
69
+ isLoading() {
70
+ return this.jsonGamaState.loading;
71
+ }
72
+ getContentError() {
73
+ return this.jsonGamaState.content_error;
74
+ }
75
+ getExperimentId() {
76
+ return this.jsonGamaState.experiment_id;
77
+ }
78
+ getModelPath() {
79
+ return this.jsonGamaState.model_path;
80
+ }
81
+ getExperimentName() {
82
+ return this.jsonGamaState.experiment_name;
83
+ }
84
+ getReadyState() {
85
+ return this.gama_socket.readyState;
86
+ }
87
+ getPort() {
88
+ return this.port;
89
+ }
90
+ getHost() {
91
+ return this.host;
92
+ }
93
+ getSocket() {
94
+ return this.gama_socket;
95
+ }
96
+ //? SETTERS --------------------------------------------------------------------------------------------------------------------------------------
97
+ setConnected(connected) {
98
+ this.jsonGamaState.connected = connected;
99
+ }
100
+ setExperimentState(state) {
101
+ this.jsonGamaState.experiment_state = state;
102
+ }
103
+ setLoading(loading) {
104
+ this.jsonGamaState.loading = loading;
105
+ }
106
+ setContentError(content_error) {
107
+ this.jsonGamaState.content_error = content_error;
108
+ }
109
+ setExperimentId(experiment_id) {
110
+ this.jsonGamaState.experiment_id = experiment_id;
111
+ }
112
+ setExperimentName(experiment_name) {
113
+ this.jsonGamaState.experiment_name = experiment_name;
114
+ }
115
+ setModelPath(model_path) {
116
+ this.jsonGamaState.model_path = model_path;
117
+ }
118
+ //? INTERNAL UTILITIES ---------------------------------------------------------------------------------------------------------------------------
119
+ /**
120
+ * internal function to avoid unecessary boilerplate code,
121
+ * checks if gamasocket exists, and if it's ready to accept a new message
122
+ */
123
+ socketCheck() {
124
+ if (!this.gama_socket) {
125
+ throw new Error("No socket connected to GAMA Server found");
126
+ } else if (!this.jsonGamaState.connected) {
127
+ throw new Error("Gama is not connected");
128
+ } else if (!(this.getReadyState() === WebSocket.OPEN || this.getReadyState() === WebSocket.CONNECTING)) {
129
+ throw new Error("socket not in the OPEN state");
130
+ } else {
131
+ logger.trace("Websocket is connected and open");
132
+ this.setConnected(true);
133
+ }
134
+ }
135
+ /**
136
+ * internal function that contains a simple try catch and stringifies a json payload to send it to the websocket
137
+ * @param payload json payload to be sent
138
+ */
139
+ sendPayload(payload) {
140
+ try {
141
+ this.gama_socket.send(JSON.stringify(payload));
142
+ logger.debug("sent message to websocket:{payload}", { payload });
143
+ } catch (error) {
144
+ throw new Error(`couldn't send the message to the websocket:${error}`);
145
+ }
146
+ }
147
+ /**
148
+ * internal function that returns the string of an experiment to run
149
+ * it represents the last used experiment or the new one if any specified
150
+ * @param new_exp_id id of the experiment passed by the user in parameter. used by default, sets the current experience to itself
151
+ * @returns the string of the Id of the last used experiment. Used if no new_exp_id is given
152
+ */
153
+ getId(new_exp_id) {
154
+ if (new_exp_id) {
155
+ this.setExperimentId(new_exp_id);
156
+ return new_exp_id;
157
+ } else {
158
+ if (this.getExperimentId() === "") throw new Error("no current experiment to be called");
159
+ return this.getExperimentId();
160
+ }
161
+ }
162
+ /**
163
+ * async function that closes the websocket connection, and runs the callback function passed in parameter if any.
164
+ * When called, creates a promise that either rejects after 15 seconds to avoid timeout lockdowns,
165
+ * or resolves after the close internalListener fires.
166
+ * @param optional callback Function to be called after the websocket's connection is closed
167
+ */
168
+ async closeConnection(callback) {
169
+ if (!this.gama_socket || this.getReadyState() === WebSocket.CLOSED) {
170
+ logger.warn("Websocket already closed, running the callback function");
171
+ if (callback) callback();
172
+ return;
173
+ }
174
+ if (this.getReadyState() === WebSocket.OPEN || this.getReadyState() === WebSocket.CONNECTING) {
175
+ await new Promise((resolve, reject) => {
176
+ const timer = setTimeout(() => {
177
+ this.gama_socket.removeEventListener("close", internalListener);
178
+ reject(new Error("Websocket timed out"));
179
+ }, 15e3);
180
+ const internalListener = () => {
181
+ clearTimeout(timer);
182
+ this.gama_socket.removeEventListener("close", internalListener);
183
+ resolve();
184
+ };
185
+ this.gama_socket.addEventListener("close", internalListener);
186
+ this.gama_socket.close();
187
+ });
188
+ if (callback) callback();
189
+ }
190
+ }
191
+ /**
192
+ * Connects the websocket client with gama server and manage the messages received
193
+ * this function is asynchronous, it needs to be called with await. This is because
194
+ * other functions need the websocket to be created and in the state "OPEN" to start
195
+ * sending messages, which is not done when the function has finished it's execution
196
+ * @returns WebSocket properly initialised at the end of the asynchronous execution
197
+ */
198
+ async connectGama() {
199
+ return new Promise((resolve, reject) => {
200
+ if (this.gama_socket && (this.getReadyState() === WebSocket.OPEN || this.getReadyState() === WebSocket.CONNECTING)) {
201
+ this.setConnected(true);
202
+ logger.info("Already connected or connecting. Skipping. status:{status}", { status: this.getReadyState() });
203
+ return resolve();
204
+ }
205
+ try {
206
+ this.gama_socket = new WebSocket(`ws://${this.host}:${this.port}`);
207
+ this.gama_socket.onopen = () => {
208
+ this.setConnected(true);
209
+ logger.info("created new connection to {host}:{port}", { host: this.host, port: this.port });
210
+ this.gama_socket.onclose = () => {
211
+ this.setConnected(false);
212
+ this.setExperimentState("NONE");
213
+ logger.info("successfully closed the websocket.");
214
+ };
215
+ const simulationStatus = (event) => {
216
+ const message = JSON.parse(event.data);
217
+ if (message.type === "SimulationStatus") {
218
+ this.setExperimentState(message.content);
219
+ this.setExperimentId(message.exp_id);
220
+ }
221
+ logger.info("JsonGamaState:{state}", { state: this.getExperimentName() });
222
+ };
223
+ this.gama_socket.addEventListener("message", simulationStatus);
224
+ return resolve();
225
+ };
226
+ this.gama_socket.onerror = (error) => {
227
+ this.setConnected(false);
228
+ if (error.error.code == "ECONNREFUSED") {
229
+ logger.trace(`full stack trace for Error CONNREFUSED {error}`, { error });
230
+ logger.error("The platform can't connect to GAMA at address {host}:{port}", { host: this.host, port: this.port });
231
+ reject(new Error(`Failed to connect to GAMA at ${this.host}:${this.port} with error code ECONNREFUSED`));
232
+ } else {
233
+ logger.error(`An error happened within the Gama Server WebSocket
234
+ {error}`, { error });
235
+ reject(new Error(`Failed to connect to GAMA at ${this.host}:${this.port}`));
236
+ }
237
+ };
238
+ } catch (e) {
239
+ const err = e;
240
+ logger.error("Synchronous error when creating the websocket:{error}", { error: err.message });
241
+ reject(e);
242
+ }
243
+ });
244
+ }
245
+ /**
246
+ * This function is used to watch on messages stream and look for a response to the command initiated.
247
+ * it resolves if the message received is of the same type specified in the parameter
248
+ * and throws an error if it's of any type specified in the GAMA_ERROR_MESSAGES specified in the constants file
249
+ * @returns returns a promise containing the response's message's content
250
+ */
251
+ async success(successMessage) {
252
+ return new Promise((resolve, reject) => {
253
+ const onMessage = (event) => {
254
+ const message = JSON.parse(event.data);
255
+ const type = message.type;
256
+ if (type === successMessage) {
257
+ this.gama_socket.removeEventListener("message", onMessage);
258
+ resolve(message);
259
+ } else if (GAMA_ERROR_MESSAGES.includes(type)) {
260
+ this.gama_socket.removeEventListener("message", onMessage);
261
+ this.setContentError(message.content);
262
+ reject(`Couldn't execute command on the Gama Server. ${type}: ${JSON.stringify(message.content)}`);
263
+ }
264
+ };
265
+ this.gama_socket.addEventListener("message", onMessage);
266
+ });
267
+ }
268
+ /**
269
+ * function used to check for a specific message on the websocket.
270
+ * returns a resolved boolean promise once the provided message is found
271
+ * @param messageType the basic type of the message you want to analyse
272
+ * @param field what part of the message to analyse
273
+ * @param expectedValue what you expect the field value to be
274
+ * @returns a resolved promise containing a boolean
275
+ */
276
+ //Voir pour retourner le message au lieu de juste retourner un booléen ?
277
+ async listenFor(messageType, field, expectedValue) {
278
+ if (!this.gama_socket) {
279
+ throw new Error("couldn't find an active gama socket when creating a listener:");
280
+ }
281
+ return new Promise((resolve, reject) => {
282
+ const listener = (event) => {
283
+ const message = JSON.parse(event.data);
284
+ logger.debug("message: {message}", { message });
285
+ const type = message.type;
286
+ if (type === messageType && message[field] === expectedValue) {
287
+ clearTimeout(timer);
288
+ resolve(true);
289
+ this.gama_socket.removeEventListener("message", listener);
290
+ }
291
+ };
292
+ const timer = setTimeout(() => {
293
+ this.gama_socket.removeEventListener("message", listener);
294
+ reject(new Error("Websocket timed out"));
295
+ }, 15e3);
296
+ this.gama_socket.addEventListener("message", listener);
297
+ logger.debug("added an event listener to the gama_socket");
298
+ });
299
+ }
300
+ async readyCheck() {
301
+ const isReady = new Promise(async (resolve, reject) => {
302
+ if (this.getExperimentState() === "PAUSED" || "RUNNING") {
303
+ resolve(true);
304
+ } else {
305
+ return await this.listenFor("SimulationStatus", "content", "PAUSED");
306
+ }
307
+ });
308
+ }
309
+ //? GAMA FUNCTIONS ---------------------------------------------------------------------------------------------------------------------------
310
+ /**
311
+ * loads and launches an experiment using the absolute path of it's model and
312
+ * the identifier of the experiment. Resolves when the server answers with a SimulationStatus of type "PAUSED"
313
+ * @param model_path absolute path pointing to the model cointaining the experiment
314
+ * @param experiment id of the experiment to load
315
+ */
316
+ async loadExperiment(model_path, experiment) {
317
+ this.socketCheck();
318
+ const payload = {
319
+ "type": "load",
320
+ "model": model_path,
321
+ "experiment": experiment
322
+ };
323
+ this.sendPayload(payload);
324
+ this.setModelPath(model_path);
325
+ this.setExperimentName(experiment);
326
+ this.setExperimentId(experiment);
327
+ await this.success("CommandExecutedSuccessfully");
328
+ return await this.readyCheck();
329
+ }
330
+ /**
331
+ * Starts or resumes the experiment specified.
332
+ * @param exp_id string name of the experiment to pause or resume
333
+ * @param sync boolean used if an end condition was specified when loading a simulation. the command will return only the SimulationEnded message if true, and both a response and a SimulationEnded message if false
334
+ * when starting the experiment
335
+ */
336
+ async play(exp_id, sync) {
337
+ this.socketCheck();
338
+ if (this.getExperimentState() === "NOTREADY") {
339
+ logger.warn("Simulation not ready yet, waiting for PAUSED simulationstatus");
340
+ await this.listenFor("Simulationstatus", "content", "PAUSED");
341
+ } else if (this.getExperimentState() === "PAUSED") {
342
+ const payload = __spreadValues({
343
+ "type": "play",
344
+ "exp_id": this.getId()
345
+ }, sync && { "sync": sync });
346
+ this.sendPayload(payload);
347
+ return await this.success("CommandExecutedSuccessfully");
348
+ } else if (this.getExperimentState() === "RUNNING") {
349
+ logger.warn("cannot unpause a running simulation");
350
+ }
351
+ }
352
+ /**
353
+ * Pauses the experiment specified.
354
+ * @param exp_id optionnal parameter, will default to last used experiment
355
+ */
356
+ async pause(exp_id) {
357
+ this.socketCheck();
358
+ const payload = {
359
+ "type": "pause",
360
+ "exp_id": this.getId(exp_id)
361
+ };
362
+ this.sendPayload(payload);
363
+ return await this.success("CommandExecutedSuccessfully");
364
+ }
365
+ async reload(exp_id, parameters, until) {
366
+ this.socketCheck();
367
+ if (this.getExperimentState() === "NOTREADY") {
368
+ await this.listenFor("Simulationstatus", "content", "PAUSED");
369
+ }
370
+ const payload = __spreadValues(__spreadValues({
371
+ "type": "reload",
372
+ "exp_id": this.getId(exp_id)
373
+ }, parameters && { "parameters": parameters }), until && { "until": until });
374
+ this.sendPayload(payload);
375
+ return await this.success("CommandExecutedSuccessfully");
376
+ }
377
+ /**
378
+ * Sends a message to gama to order it to process a specified number of steps.
379
+ * Can only be used after the simulation has already been loaded
380
+ * @param exp_id the name of the experiment you want to step to. if not used, then the last used experiment Id will be used
381
+ * @param nb_step the number of steps you want to simulate. if none is specified, it will default to one step
382
+ */
383
+ async step(nb_step, sync, exp_id) {
384
+ this.socketCheck();
385
+ if (this.getExperimentState() === "NOTREADY") {
386
+ logger.warn("The experiment is not yet ready:{state}", { state: this.getExperimentState() });
387
+ await this.listenFor("Simulationstatus", "content", "PAUSED");
388
+ }
389
+ const exp_id_payload = exp_id ? exp_id : this.getExperimentId();
390
+ if (exp_id_payload === "") throw new Error("no experience_id specified, and no experiment in the jsongamastate");
391
+ const payload = __spreadProps(__spreadValues({
392
+ "type": "step",
393
+ "exp_id": exp_id_payload
394
+ }, nb_step && { "nb_step": nb_step }), {
395
+ "sync": true
396
+ });
397
+ this.sendPayload(payload);
398
+ return await this.success("CommandExecutedSuccessfully");
399
+ }
400
+ /**
401
+ * ! you must be sure that the type of the experiment is compatible (record) before using this
402
+ * This command is used to rollback a specific amount of steps.
403
+ * Can only be used if the experiment is of type "record"
404
+ * @param exp_id the name of the experiment you want to step to. if not used, then the last used experiment Id will be used
405
+ * @param nb_step the number of steps you want to simulate. if none is specified, it will default to one step
406
+ */
407
+ async stepback(nb_step, exp_id) {
408
+ this.socketCheck();
409
+ const payload = __spreadProps(__spreadValues({
410
+ "type": "stepBack",
411
+ "exp_id": this.getId(exp_id)
412
+ }, nb_step && { "nb_step": nb_step }), {
413
+ "sync": true
414
+ });
415
+ this.sendPayload(payload);
416
+ return await this.success("CommandExecutedSuccessfully");
417
+ }
418
+ /**
419
+ * stops the specified experiment or the current experiment if not specified
420
+ * @param exp_id optionnal parameter, leave empty to use the last used exp_id
421
+ */
422
+ async stop(exp_id) {
423
+ this.socketCheck();
424
+ if (this.getExperimentState() !== "NONE") {
425
+ try {
426
+ const payload = {
427
+ "type": "stop",
428
+ "exp_id": this.getId(exp_id)
429
+ };
430
+ this.sendPayload(payload);
431
+ return await this.success("CommandExecutedSuccessfully");
432
+ } catch (error) {
433
+ throw new Error(`couldn't stop the experiment:${error}`);
434
+ }
435
+ } else {
436
+ logger.warn(`couldn't stop the experiment, no experiment running`);
437
+ return new Promise((resolve) => {
438
+ resolve("couldn't stop experiment");
439
+ });
440
+ }
441
+ }
442
+ /**
443
+ * used to specify a fonction to be called on any message received by the websocket from the gama server
444
+ * you can only have one onMessage per client.
445
+ * @param callback the function you want to call upon receiving data through the javascript client
446
+ */
447
+ onMessage(callback) {
448
+ if (!this.gama_socket) {
449
+ throw new Error("WebSocket is not initialized");
450
+ }
451
+ this.gama_socket.on("message", (data) => {
452
+ try {
453
+ const parsed = JSON.parse(data.toString());
454
+ callback(parsed);
455
+ } catch (err) {
456
+ logger.warn("Received non-JSON message:{data}", { data });
457
+ callback(data);
458
+ }
459
+ });
460
+ }
461
+ /**
462
+ * kills the gama server.
463
+ * used to exit the gama server, closes the websocket connection and closes the gama instance
464
+ */
465
+ async killGamaServer() {
466
+ this.socketCheck();
467
+ const payload = { "type": "exit" };
468
+ this.sendPayload(payload);
469
+ }
470
+ /**
471
+ * used to run execute an action defined in an agent in an experiment.
472
+ * @param action gaml code to be run from an agent
473
+ * @param args arguments of the action
474
+ * @param agent what agent this code applies to
475
+ * @param escaped optional parameter, if true will escape the action and args before sending them to gama
476
+ * @param exp_id optionnal parameter to specify the experiment. if none is given it will instead default to the last used experiment
477
+ * @returns a stringified response containing the result of the execution of the command
478
+ */
479
+ async ask(action, args, agent, escaped, exp_id) {
480
+ this.socketCheck();
481
+ var payload = __spreadValues({
482
+ "type": "ask",
483
+ "exp_id": this.getId(exp_id),
484
+ "action": action,
485
+ "args": args,
486
+ "agent": agent
487
+ }, escaped && { "escaped": escaped });
488
+ this.sendPayload(payload);
489
+ return await this.success("CommandExecutedSuccessfully");
490
+ }
491
+ /**
492
+ * Compiles the code given in parameter and returns a message if any errors are detected.
493
+ * @param expr gaml expression to test
494
+ * @param syntax optionnal boolean, if true will only check the syntax. false will check for both syntactical and semantic errors
495
+ * @param escaped optionnal boolean, dictates if the expression is escaped or not
496
+ * @returns stringified json containing errors in the code if any
497
+ */
498
+ async validate(expr, syntax, escaped) {
499
+ this.socketCheck();
500
+ const payload = __spreadValues(__spreadValues({
501
+ "type": "validate",
502
+ "expr": expr
503
+ }, syntax && { "syntax": syntax }), escaped && { "escaped": escaped });
504
+ this.sendPayload(payload);
505
+ try {
506
+ return await this.success("CommandExecutedSuccessfully");
507
+ } catch (err) {
508
+ throw err;
509
+ }
510
+ }
511
+ /**
512
+ * This command is used to ask the server more information on a given model. When received,
513
+ * the server will compile the model and return the different components found, depending on the option picked by the client.
514
+ * @param model_path path to the model to evaluate
515
+ * @param experimentsNames optional boolean that returns the name of all the experiments of the model
516
+ * @param speciesNames optional boolean that returns all of the species' names
517
+ * @param speciesVariables optional boolean that returns all variables of the species
518
+ * @param speciesActions optional boolean that returns all actions in the species
519
+ */
520
+ async describe(model_path, experimentsNames, speciesNames, speciesVariables, speciesActions) {
521
+ this.socketCheck();
522
+ const payload = __spreadValues(__spreadValues(__spreadValues(__spreadValues({
523
+ "type": "describe",
524
+ "model": model_path
525
+ }, experimentsNames && { "experiments": experimentsNames }), speciesNames && { "speciesNames": speciesNames }), speciesActions && { "speciesActions": speciesActions }), speciesVariables && { "speciesVariables": speciesVariables });
526
+ this.sendPayload(payload);
527
+ return await this.success("CommandExecutedSuccessfully");
528
+ }
529
+ };
530
+ export {
531
+ GamaClient as default
532
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@gama-platform/gama-client",
3
+ "version": "1.0.2",
4
+ "type": "module",
5
+ "description": "library to control simulations from a js client",
6
+ "main": "./dist/gama_client.cjs",
7
+ "module": "./dist/gama_client.js",
8
+ "types": "./dist/gama_client.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/gama_client.js",
12
+ "require": "./dist/gama_client.cjs",
13
+ "types": "./dist/gama_client.d.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "@logtape/logtape": "^2.0.4",
21
+ "path": "^0.12.7",
22
+ "react": "^18.3.1",
23
+ "ws": "^8.17.1"
24
+ },
25
+ "devDependencies": {
26
+ "@babel/preset-typescript": "^7.27.1",
27
+ "@tsconfig/node20": "^20.1.5",
28
+ "@types/jest": "^29.5.14",
29
+ "@types/ws": "^8.18.1",
30
+ "jest": "^29.7.0",
31
+ "ts-jest": "^29.3.3",
32
+ "ts-node": "^10.9.2",
33
+ "tsx": "^4.19.4",
34
+ "tsup": "^8.4.0",
35
+ "typescript": "^5.8.3"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup src/gama_client.ts --format esm,cjs --dts --out-dir dist",
39
+ "test": "npx jest --detectOpenHandles"
40
+ }
41
+ }