@aikoql/client 0.2.3 → 0.2.4

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
@@ -5,7 +5,9 @@ The first-party TypeScript client for the [aikoql](https://github.com/anckursing
5
5
  ```ts
6
6
  import { Client } from "@aikoql/client";
7
7
 
8
- const client = await Client.dial("127.0.0.1:9090", { token: "s3cret" });
8
+ const client = (await Client.dial("127.0.0.1:9090")).withToken("s3cret");
9
+ // a server started with --tls-cert/--tls-key ({} for a public CA):
10
+ // await Client.dial("db.internal:9090", { tls: { ca: readFileSync("ca.pem") } })
9
11
  const agent = await client.agent();
10
12
  await agent.remember("person", { name: "ada" });
11
13
  for await (const row of client.queryStream("MATCH person RETURN *")) { /* ... */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikoql/client",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Aikoql — the TypeScript SDK for the agent-first knowledge database. Node + browser-compatible core, async/await, AsyncIterable streaming, AbortSignal, pool. Zero runtime dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "author": {
package/src/client.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // transport changes).
11
11
 
12
12
  import { randomBytes } from "node:crypto";
13
+ import type { ConnectionOptions } from "node:tls";
13
14
  import { readFileSync } from "node:fs";
14
15
  import { McpError } from "./error.ts";
15
16
  import { TcpTransport, type Transport } from "./tcp.ts";
@@ -28,7 +29,7 @@ import { Tx } from "./tx.ts";
28
29
  * The oldest aikoql-mcp server this SDK will talk to (the ND-12 version
29
30
  * contract, mirrored from the Go, Python and Rust SDKs).
30
31
  */
31
- export const MIN_SERVER_VERSION = "0.2.3";
32
+ export const MIN_SERVER_VERSION = "0.2.4";
32
33
 
33
34
  /** The SDK's own package version, advertised as the client identity:
34
35
  * read from package.json so the manifest, not a literal, owns it. */
@@ -98,6 +99,19 @@ export function streamNotify(frame: Record<string, unknown>, streamId: string):
98
99
  return p;
99
100
  }
100
101
 
102
+ /** One wire line as a JSON object, or undefined for noise: not JSON, or
103
+ * JSON that is not an object (null, a number, a string, an array) — never
104
+ * a response, never a notify. */
105
+ function parseFrame(line: string): Record<string, unknown> | undefined {
106
+ let v: unknown;
107
+ try {
108
+ v = JSON.parse(line);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
113
+ }
114
+
101
115
  /**
102
116
  * One MCP JSON-RPC connection to an aikoql-mcp server. One in-flight call
103
117
  * per connection (a stream holds the connection for its whole life); calls
@@ -113,17 +127,26 @@ export class Client {
113
127
  // The open stream's release (null when none): close() cancels a
114
128
  // mid-stream close through it (§17).
115
129
  private streamGate: (() => void) | null = null;
130
+ // The TCP address dialed (undefined for other transports) — moved by a
131
+ // NOT_LEADER leader hint (MRFC-0080 R5b).
132
+ private addr: string | undefined;
133
+ // The TLS options a leader hint is re-dialed with too (none: plaintext).
134
+ private tls: ConnectionOptions | undefined;
116
135
 
117
- private constructor(transport: Transport) {
136
+ private constructor(transport: Transport, addr?: string, tlsOptions?: ConnectionOptions) {
118
137
  this.transport = transport;
138
+ this.addr = addr;
139
+ this.tls = tlsOptions;
119
140
  }
120
141
 
121
142
  /** Opens a TCP connection ("host:port"). The handshake is separate
122
143
  * (initialize) so a pooled client can dial first and authenticate later;
123
- * every aikoql-mcp TCP server requires the token. */
124
- static async dial(addr: string): Promise<Client> {
125
- const transport = await TcpTransport.connect(addr, DIAL_TIMEOUT_MS);
126
- return new Client(transport);
144
+ * every aikoql-mcp TCP server requires the token. `opts.tls` (Node's
145
+ * tls.ConnectionOptions — e.g. `{ ca }` for a private CA; `{}` for the
146
+ * system roots) dials a server started with --tls-cert/--tls-key. */
147
+ static async dial(addr: string, opts: { tls?: ConnectionOptions } = {}): Promise<Client> {
148
+ const transport = await TcpTransport.connect(addr, DIAL_TIMEOUT_MS, opts.tls);
149
+ return new Client(transport, addr, opts.tls);
127
150
  }
128
151
 
129
152
  /** Sends the --tcp-token credential in the initialize handshake
@@ -192,12 +215,8 @@ export class Client {
192
215
  this.closed = true;
193
216
  throw McpError.io("connection closed by the server");
194
217
  }
195
- let resp: Record<string, unknown>;
196
- try {
197
- resp = JSON.parse(line) as Record<string, unknown>;
198
- } catch {
199
- continue; // tolerate non-JSON noise frames
200
- }
218
+ const resp = parseFrame(line);
219
+ if (resp === undefined) continue; // non-JSON or non-object noise
201
220
  const rid = resp["id"] ?? 0;
202
221
  if (typeof rid !== "number") continue; // not our numeric correlation
203
222
  switch (classifyID(id, rid)) {
@@ -243,11 +262,42 @@ export class Client {
243
262
  }
244
263
 
245
264
  /** Calls any registered MCP tool by name and returns its data payload —
246
- * the escape hatch for tools without a typed wrapper here. */
265
+ * the escape hatch for tools without a typed wrapper here.
266
+ *
267
+ * MRFC-0080 R5b: refused with NOT_LEADER and a leader hint, a TCP
268
+ * client reconnects to that leader, re-runs initialize and retries the
269
+ * call once; it then stays on the leader. sessionInit identity is not
270
+ * replayed. */
247
271
  async callTool(
248
272
  name: string,
249
273
  args?: unknown,
250
274
  opts: { signal?: AbortSignal } = {},
275
+ ): Promise<unknown> {
276
+ try {
277
+ return await this.callToolOnce(name, args, opts);
278
+ } catch (e) {
279
+ if (!(e instanceof McpError) || e.code !== "NOT_LEADER" || !e.leader || this.addr === undefined) throw e;
280
+ await this.follow(e.leader, opts.signal);
281
+ return this.callToolOnce(name, args, opts);
282
+ }
283
+ }
284
+
285
+ /** Replaces the connection with one to the leader at `addr` and
286
+ * re-runs the handshake. */
287
+ private async follow(addr: string, signal?: AbortSignal): Promise<void> {
288
+ const next = await TcpTransport.connect(addr, DIAL_TIMEOUT_MS, this.tls);
289
+ await this.withLock(async () => {
290
+ this.transport.close();
291
+ this.transport = next;
292
+ this.addr = addr;
293
+ });
294
+ await this.initialize({ signal });
295
+ }
296
+
297
+ private async callToolOnce(
298
+ name: string,
299
+ args?: unknown,
300
+ opts: { signal?: AbortSignal } = {},
251
301
  ): Promise<unknown> {
252
302
  const params: Record<string, unknown> = { name };
253
303
  if (args !== undefined) params.arguments = args;
@@ -270,12 +320,14 @@ export class Client {
270
320
  message?: string;
271
321
  retryable?: boolean;
272
322
  suggestion?: string;
323
+ leader?: string;
273
324
  };
274
325
  throw new McpError(
275
326
  e.code ?? "INTERNAL",
276
327
  e.message ?? "",
277
328
  e.retryable ?? false,
278
329
  e.suggestion ?? "",
330
+ e.leader,
279
331
  );
280
332
  }
281
333
  throw new McpError(
@@ -336,12 +388,8 @@ export class Client {
336
388
  this.closed = true;
337
389
  throw McpError.io("connection closed by the server");
338
390
  }
339
- let resp: Record<string, unknown>;
340
- try {
341
- resp = JSON.parse(line) as Record<string, unknown>;
342
- } catch {
343
- continue;
344
- }
391
+ const resp = parseFrame(line);
392
+ if (resp === undefined) continue; // non-JSON or non-object noise
345
393
  const rid = resp["id"] ?? 0;
346
394
  if (typeof rid === "number" && rid === id) {
347
395
  if (resp["error"] !== undefined) {
package/src/error.ts CHANGED
@@ -17,13 +17,16 @@ export class McpError extends Error {
17
17
  code: string;
18
18
  retryable: boolean;
19
19
  suggestion: string;
20
+ /** The leader's client address on a NOT_LEADER refusal (MRFC-0080 R4d). */
21
+ leader?: string;
20
22
 
21
- constructor(code: string, message: string, retryable = false, suggestion = "") {
23
+ constructor(code: string, message: string, retryable = false, suggestion = "", leader?: string) {
22
24
  super(`[${code}] ${message}`);
23
25
  this.name = "McpError";
24
26
  this.code = code;
25
27
  this.retryable = retryable;
26
28
  this.suggestion = suggestion;
29
+ if (leader !== undefined) this.leader = leader;
27
30
  }
28
31
 
29
32
  /** The frozen TIMEOUT (retryable): a deadline elapsed before a response. */
package/src/pool.ts CHANGED
@@ -56,6 +56,9 @@ export class PooledConn {
56
56
  private p: Pool;
57
57
  private c: Client;
58
58
  private tx: Tx | null = null;
59
+ // performance.now() milliseconds (monotonic, sub-ms): a borrow in the same
60
+ // millisecond as the release still sees time pass, so healthCheckIntervalMs
61
+ // 0 pings every borrow
59
62
  createdAt: number;
60
63
  lastUsed: number;
61
64
  broken = false;
@@ -144,7 +147,7 @@ export class Pool {
144
147
  await pc.client().close().catch(() => {});
145
148
  continue;
146
149
  }
147
- const now = Date.now();
150
+ const now = performance.now();
148
151
  if (now - pc.lastUsed > this.cfg.healthCheckIntervalMs) {
149
152
  // Ping: a dead connection is dropped and replaced by a fresh
150
153
  // dial (the reconnect leg of §21).
@@ -170,7 +173,7 @@ export class Pool {
170
173
  this.total--;
171
174
  throw e;
172
175
  }
173
- const now = Date.now();
176
+ const now = performance.now();
174
177
  return new PooledConn(this, c, now);
175
178
  }
176
179
  // Exhausted: wait for a return or the acquire timeout.
@@ -200,7 +203,7 @@ export class Pool {
200
203
  * reconnect leg: a dead server keeps the borrow pending and succeeds
201
204
  * once the server returns. */
202
205
  private async dialRetry(): Promise<Client> {
203
- const deadline = Date.now() + this.cfg.acquireTimeoutMs;
206
+ const deadline = performance.now() + this.cfg.acquireTimeoutMs;
204
207
  let lastErr: unknown;
205
208
  for (;;) {
206
209
  try {
@@ -208,7 +211,7 @@ export class Pool {
208
211
  } catch (e) {
209
212
  lastErr = e;
210
213
  }
211
- if (Date.now() >= deadline) {
214
+ if (performance.now() >= deadline) {
212
215
  throw new McpError(
213
216
  "UNAVAILABLE",
214
217
  `could not connect to the server: ${String(lastErr)}`,
@@ -223,7 +226,7 @@ export class Pool {
223
226
  /** Whether an idle connection was reaped by the idle or lifetime
224
227
  * timeout. */
225
228
  private expired(pc: PooledConn): boolean {
226
- const now = Date.now();
229
+ const now = performance.now();
227
230
  return (
228
231
  now - pc.lastUsed > this.cfg.idleTimeoutMs ||
229
232
  now - pc.createdAt > this.cfg.maxLifetimeMs
@@ -235,7 +238,7 @@ export class Pool {
235
238
  * dropped instead. Public only for PooledConn — the stand-in for Go's
236
239
  * same-package access. */
237
240
  put(pc: PooledConn): void {
238
- const now = Date.now();
241
+ const now = performance.now();
239
242
  pc.lastUsed = now;
240
243
  const drop =
241
244
  this.closedFlag || pc.broken || now - pc.createdAt > this.cfg.maxLifetimeMs;
@@ -270,7 +273,7 @@ export class Pool {
270
273
  this.total--;
271
274
  throw e;
272
275
  }
273
- this.put(new PooledConn(this, c, Date.now()));
276
+ this.put(new PooledConn(this, c, performance.now()));
274
277
  }
275
278
  }
276
279
 
package/src/tcp.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // D-15 native protocol replacement likewise swaps only the Transport.
5
5
 
6
6
  import net from "node:net";
7
+ import tls from "node:tls";
7
8
  import { McpError } from "./error.ts";
8
9
 
9
10
  /** §19: a malicious server cannot cause unbounded client memory — an
@@ -69,16 +70,27 @@ export class TcpTransport implements Transport {
69
70
  socket.on("error", () => {});
70
71
  }
71
72
 
72
- /** Connects, bounding the dial by the frozen TIMEOUT (the Go/Rust shape). */
73
- static connect(addr: string, timeoutMs: number): Promise<TcpTransport> {
73
+ /** Connects, bounding the dial by the frozen TIMEOUT (the Go/Rust shape).
74
+ * With `tlsOptions` (a server started with --tls-cert/--tls-key) the
75
+ * handshake verifies the certificate for `host` — an IP against the SAN,
76
+ * a DNS name also sent as SNI — before resolving, so the token is never
77
+ * sent to an unverified peer. */
78
+ static connect(addr: string, timeoutMs: number, tlsOptions?: tls.ConnectionOptions): Promise<TcpTransport> {
74
79
  return new Promise((resolve, reject) => {
75
80
  const [host, port] = addr.split(":");
76
- const socket = net.connect(Number(port), host!);
81
+ const socket = tlsOptions
82
+ ? tls.connect({
83
+ host,
84
+ port: Number(port),
85
+ ...(net.isIP(host!) ? {} : { servername: host }),
86
+ ...tlsOptions,
87
+ })
88
+ : net.connect(Number(port), host!);
77
89
  const timer = setTimeout(() => {
78
90
  socket.destroy();
79
91
  reject(McpError.deadline());
80
92
  }, timeoutMs);
81
- socket.once("connect", () => {
93
+ socket.once(tlsOptions ? "secureConnect" : "connect", () => {
82
94
  clearTimeout(timer);
83
95
  resolve(new TcpTransport(socket));
84
96
  });
@@ -176,7 +176,8 @@ test("the envelope property", async () => {
176
176
  async (addr) => {
177
177
  const c = await Client.dial(addr);
178
178
  try {
179
- assert.deepEqual(await c.callTool("probe"), env);
179
+ // what JSON can carry: jsonValue() may yield -0, which encodes as 0
180
+ assert.deepEqual(await c.callTool("probe"), JSON.parse(JSON.stringify(env)));
180
181
  } finally {
181
182
  await c.close().catch(() => {});
182
183
  }
@@ -0,0 +1,50 @@
1
+ // MRFC-0080 R5b — the client follows a NOT_LEADER leader hint: a fenced
2
+ // server refuses a write with NOT_LEADER and the newer leader's client
3
+ // address; the call is retried once against that leader over a fresh,
4
+ // initialized connection, and the client stays there.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import { McpError, MIN_SERVER_VERSION } from "../src/index.ts";
9
+ import { dial, respond, scripted, toolResult } from "./helpers.ts";
10
+
11
+ function notLeader(id: unknown, leader?: string): string {
12
+ const error: Record<string, unknown> = { code: "NOT_LEADER", message: "not leader", retryable: true };
13
+ if (leader !== undefined) error.leader = leader;
14
+ return JSON.stringify({
15
+ id,
16
+ result: { content: [{ text: JSON.stringify({ ok: false, error }) }], isError: true },
17
+ });
18
+ }
19
+
20
+ test("NOT_LEADER follows the leader hint", async (t) => {
21
+ const saw: string[] = [];
22
+ const leader = await scripted(t, (req) => {
23
+ saw.push(req["method"] as string);
24
+ return req["method"] === "initialize"
25
+ ? [[respond(req["id"], MIN_SERVER_VERSION), 0]]
26
+ : [[toolResult(req["id"], { koid: "ko-1" }), 0]];
27
+ });
28
+ const fenced = await scripted(t, (req) =>
29
+ req["method"] === "initialize"
30
+ ? [[respond(req["id"], MIN_SERVER_VERSION), 0]]
31
+ : [[notLeader(req["id"], leader), 0]],
32
+ );
33
+ const c = (await dial(t, fenced)).withToken("t");
34
+ await c.initialize();
35
+ assert.deepEqual(await c.callTool("remember", { type_name: "note" }), { koid: "ko-1" });
36
+ await c.callTool("remember", { type_name: "note" });
37
+ assert.deepEqual(saw, ["initialize", "tools/call", "tools/call"]);
38
+ });
39
+
40
+ test("NOT_LEADER without a hint is thrown", async (t) => {
41
+ const addr = await scripted(t, (req) => [[notLeader(req["id"]), 0]]);
42
+ const c = await dial(t, addr);
43
+ await assert.rejects(c.callTool("remember", {}), (e: unknown) => {
44
+ assert.ok(e instanceof McpError);
45
+ assert.equal(e.code, "NOT_LEADER");
46
+ assert.equal(e.retryable, true);
47
+ assert.equal(e.leader, undefined);
48
+ return true;
49
+ });
50
+ });
@@ -65,6 +65,25 @@ function poolFactory(t: TestContext, respond: (req: Record<string, unknown>) =>
65
65
  };
66
66
  }
67
67
 
68
+ // healthCheckIntervalMs: 0 pings every borrow — even one in the same
69
+ // millisecond as the release (a whole-millisecond clock skipped the ping,
70
+ // handing back a connection whose server had just died: the restart-test
71
+ // flake below).
72
+ test("a zero health-check interval pings every borrow", async (t) => {
73
+ const log: TxnCall[] = [];
74
+ const { factory } = poolFactory(t, poolResponder(log));
75
+ const p = new Pool({ factory, maxConns: 1, healthCheckIntervalMs: 0 });
76
+ t.after(() => {
77
+ void p.close();
78
+ });
79
+ for (let i = 0; i < 20; i++) {
80
+ const pc = await p.acquire();
81
+ await pc.release();
82
+ }
83
+ // the first borrow dials (nothing idle to ping); each of the other 19 pings
84
+ assert.equal(log.filter((c) => c.name === "health").length, 19);
85
+ });
86
+
68
87
  test("exhaustion waits then RESOURCE_EXHAUSTED; a freed connection goes to a waiter", async (t) => {
69
88
  const log: TxnCall[] = [];
70
89
  const { factory, dials } = poolFactory(t, poolResponder(log));
@@ -242,9 +261,9 @@ test(
242
261
  }
243
262
  return c;
244
263
  };
245
- // A near-zero health-check interval: every borrow pings, so a dead
264
+ // A zero health-check interval: every borrow pings, so a dead
246
265
  // connection is caught and replaced at checkout.
247
- const p = new Pool({ factory, maxConns: 1, acquireTimeoutMs: 10_000, healthCheckIntervalMs: 1 });
266
+ const p = new Pool({ factory, maxConns: 1, acquireTimeoutMs: 10_000, healthCheckIntervalMs: 0 });
248
267
  t.after(() => {
249
268
  void p.close();
250
269
  });
@@ -0,0 +1,74 @@
1
+ // The SDK over TLS against the real server started with --tls-cert /
2
+ // --tls-key (the MCP crate's test certificates, SAN IP:127.0.0.1):
3
+ // Client.dial(addr, { tls: { ca } }) initializes and round-trips a write; a
4
+ // server outside the trust anchors is refused at the dial, before the token
5
+ // is sent. Skipped without AIKOQL_MCP_BIN.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import net from "node:net";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { test, type TestContext } from "node:test";
14
+ import { fileURLToPath } from "node:url";
15
+ import { Client } from "../src/index.ts";
16
+ import { sleep } from "./helpers.ts";
17
+
18
+ const FX = path.resolve(
19
+ path.dirname(fileURLToPath(import.meta.url)),
20
+ "../../../services/api/mcp/tests/fixtures/tls",
21
+ );
22
+ const ca = (name: string) => fs.readFileSync(path.join(FX, name));
23
+ const skip =
24
+ process.env.AIKOQL_MCP_BIN === undefined ? "AIKOQL_MCP_BIN not set — TLS real-server test skipped" : false;
25
+
26
+ async function startTlsServer(t: TestContext): Promise<string> {
27
+ const probe = net.createServer();
28
+ await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
29
+ const port = (probe.address() as net.AddressInfo).port;
30
+ await new Promise<void>((resolve) => probe.close(() => resolve()));
31
+ const addr = `127.0.0.1:${port}`;
32
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ts-tls-"));
33
+ const child: ChildProcess = spawn(
34
+ process.env.AIKOQL_MCP_BIN!,
35
+ [
36
+ "serve", "--listen", addr, "--tcp-token", "ts-tls::admin",
37
+ "--tls-cert", path.join(FX, "server.pem"), "--tls-key", path.join(FX, "server.key"),
38
+ path.join(dir, "kb"),
39
+ ],
40
+ { stdio: ["ignore", "ignore", "ignore"], windowsHide: true },
41
+ );
42
+ t.after(async () => {
43
+ child.kill();
44
+ if (child.exitCode === null) await new Promise<void>((resolve) => child.once("exit", () => resolve()));
45
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
46
+ });
47
+ const deadline = Date.now() + 10_000;
48
+ for (;;) {
49
+ const up = await new Promise<boolean>((resolve) => {
50
+ const s = net.connect(port, "127.0.0.1");
51
+ s.once("connect", () => { s.destroy(); resolve(true); });
52
+ s.once("error", () => resolve(false));
53
+ });
54
+ if (up) return addr;
55
+ if (Date.now() >= deadline) throw new Error(`TLS server never came up on ${addr}`);
56
+ await sleep(100);
57
+ }
58
+ }
59
+
60
+ test("MCP over TLS", { skip }, async (t) => {
61
+ const addr = await startTlsServer(t);
62
+ const c = (await Client.dial(addr, { tls: { ca: ca("ca.pem") } })).withToken("ts-tls");
63
+ t.after(() => c.close());
64
+ await c.initialize();
65
+ const put = (await c.callTool("remember", { subject: "admin", type_name: "note", properties: { n: 7 } })) as { koid: string };
66
+ assert.ok(put.koid, "remember over TLS returns a koid");
67
+ const got = (await c.callTool("get", { subject: "admin", koid: put.koid })) as { properties: { n: number } };
68
+ assert.equal(got.properties.n, 7);
69
+ });
70
+
71
+ test("an untrusted server is refused at the dial", { skip }, async (t) => {
72
+ const addr = await startTlsServer(t);
73
+ await assert.rejects(Client.dial(addr, { tls: { ca: ca("other-ca.pem") } }));
74
+ });
@@ -189,3 +189,19 @@ test("a late response after a timeout is skipped (self-healing)", async (t) => {
189
189
  // (id 1 < id 2); the real answer for id 2 lands.
190
190
  await c.initialize();
191
191
  });
192
+
193
+ // A line that is valid JSON but not an object (null, a number, a string, an
194
+ // array) is a malformed frame: skipped, never read as the response — the
195
+ // Python SDK's crash on `0` (fuzz estate) checked across the SDKs; here a
196
+ // `null` line threw a TypeError at resp["id"].
197
+ test("a frame that is JSON but not an object is skipped", async (t) => {
198
+ const addr = await scripted(t, (req) => [
199
+ ["null", 0],
200
+ ["0", 0],
201
+ ['"s"', 0],
202
+ ["[1]", 0],
203
+ [toolResult(req["id"], { ok: true }), 0],
204
+ ]);
205
+ const c = await dial(t, addr);
206
+ assert.deepEqual(await c.callTool("probe", {}), { ok: true });
207
+ });