From a0934549280dc412c4b3adbd57483b6266be0f9e Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Wed, 25 Mar 2026 19:02:05 +0600 Subject: [PATCH 1/3] Fix CORS preflight and transport upgrade handling Move OPTIONS response after editResponseHeaders so custom headers are included in preflight responses. Allow websocket upgrades from polling when an Upgrade header is present; reject other invalid transport transitions with TRANSPORT_MISMATCH. Refactor Socket.IO client handshake creation (createHandshakeBase), fix socket removal key, and update/add tests accordingly. --- packages/engine.io/lib/server.ts | 36 +-- .../engine.io/test/response_headers.test.ts | 30 +++ packages/engine.io/test/verification.test.ts | 146 ++++++++---- packages/socket.io/lib/client.ts | 29 ++- packages/socket.io/test/client.test.ts | 38 +++ packages/socket.io/test/handshake.test.ts | 216 ++++++++++-------- 6 files changed, 330 insertions(+), 165 deletions(-) create mode 100644 packages/socket.io/test/client.test.ts diff --git a/packages/engine.io/lib/server.ts b/packages/engine.io/lib/server.ts index 208b2f8..57bf6bb 100644 --- a/packages/engine.io/lib/server.ts +++ b/packages/engine.io/lib/server.ts @@ -162,16 +162,18 @@ export class Server extends EventEmitter< const responseHeaders = new Headers(); if (this.opts.cors) { addCorsHeaders(responseHeaders, this.opts.cors, req); - - if (req.method === "OPTIONS") { - return new Response(null, { status: 204, headers: responseHeaders }); - } } if (this.opts.editResponseHeaders) { await this.opts.editResponseHeaders(responseHeaders, req, connInfo); } + if (this.opts.cors) { + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: responseHeaders }); + } + } + try { await this.verify(req, url); } catch (err) { @@ -278,20 +280,26 @@ export class Server extends EventEmitter< }); } const previousTransport = client.transport.name; - if (previousTransport === "websocket") { + const isUpgradeRequest = req.headers.has("upgrade"); + const isValidUpgrade = previousTransport === "polling" && + transport === "websocket" && + isUpgradeRequest; + + if ( + previousTransport === "websocket" || + (!isValidUpgrade && transport !== previousTransport) + ) { getLogger("engine.io").debug( "[server] unexpected transport without upgrade", ); - return Promise.reject( - { - code: ERROR_CODES.BAD_REQUEST, - context: { - name: "TRANSPORT_MISMATCH", - transport, - previousTransport, - }, + return Promise.reject({ + code: ERROR_CODES.BAD_REQUEST, + context: { + name: "TRANSPORT_MISMATCH", + transport, + previousTransport, }, - ); + }); } } else { // handshake is GET only diff --git a/packages/engine.io/test/response_headers.test.ts b/packages/engine.io/test/response_headers.test.ts index 5818e61..19ba419 100644 --- a/packages/engine.io/test/response_headers.test.ts +++ b/packages/engine.io/test/response_headers.test.ts @@ -65,4 +65,34 @@ describe("response headers", () => { socket.onopen = done; }); }); + + it("should send custom response headers for preflight requests", () => { + const engine = new Server({ + cors: { + origin: ["https://example.com"], + }, + editResponseHeaders: (responseHeaders) => { + responseHeaders.set("x-test", "123"); + }, + }); + + return setup(engine, 1, async (port, done) => { + const response = await fetch( + `http://localhost:${port}/engine.io/?EIO=4&transport=polling`, + { + method: "OPTIONS", + headers: { + origin: "https://example.com", + }, + }, + ); + + assertEquals(response.status, 204); + assertEquals(response.headers.get("x-test"), "123"); + + await response.body?.cancel(); + + done(); + }); + }); }); diff --git a/packages/engine.io/test/verification.test.ts b/packages/engine.io/test/verification.test.ts index d756eaf..711c459 100644 --- a/packages/engine.io/test/verification.test.ts +++ b/packages/engine.io/test/verification.test.ts @@ -177,69 +177,61 @@ describe("verification", () => { it("should disallow invalid handshake method", () => { const engine = new Server(); - return setup( - engine, - 2, - async (port, partialDone) => { - engine.on("connection_error", (err) => { - assertExists(err.req); - assertEquals(err.code, 2); - assertEquals(err.message, "Bad handshake method"); - assertEquals(err.context.method, "PUT"); - - partialDone(); - }); + return setup(engine, 2, async (port, partialDone) => { + engine.on("connection_error", (err) => { + assertExists(err.req); + assertEquals(err.code, 2); + assertEquals(err.message, "Bad handshake method"); + assertEquals(err.context.method, "PUT"); - const response = await fetch( - `http://localhost:${port}/engine.io/?transport=polling`, - { - method: "put", - }, - ); + partialDone(); + }); - assertEquals(response.status, 400); + const response = await fetch( + `http://localhost:${port}/engine.io/?transport=polling`, + { + method: "put", + }, + ); - const body = await response.json(); - assertEquals(body.code, 2); - assertEquals(body.message, "Bad handshake method"); + assertEquals(response.status, 400); - partialDone(); - }, - ); + const body = await response.json(); + assertEquals(body.code, 2); + assertEquals(body.message, "Bad handshake method"); + + partialDone(); + }); }); it("should disallow unsupported protocol versions", () => { const engine = new Server(); - return setup( - engine, - 2, - async (port, partialDone) => { - engine.on("connection_error", (err) => { - assertExists(err.req); - assertEquals(err.code, 5); - assertEquals(err.message, "Unsupported protocol version"); - assertEquals(err.context.protocol, 3); + return setup(engine, 2, async (port, partialDone) => { + engine.on("connection_error", (err) => { + assertExists(err.req); + assertEquals(err.code, 5); + assertEquals(err.message, "Unsupported protocol version"); + assertEquals(err.context.protocol, 3); - partialDone(); - }); + partialDone(); + }); - const response = await fetch( - `http://localhost:${port}/engine.io/?EIO=3&transport=polling`, - { - method: "get", - }, - ); + const response = await fetch( + `http://localhost:${port}/engine.io/?EIO=3&transport=polling`, + { + method: "get", + }, + ); - assertEquals(response.status, 400); + assertEquals(response.status, 400); - const body = await response.json(); - assertEquals(body.code, 5); - assertEquals(body.message, "Unsupported protocol version"); + const body = await response.json(); + assertEquals(body.code, 5); + assertEquals(body.message, "Unsupported protocol version"); - partialDone(); - }, - ); + partialDone(); + }); }); it("should disallow invalid transport", () => { @@ -301,4 +293,60 @@ describe("verification", () => { }; }); }); + + it("should disallow transport mismatch for an existing polling session", () => { + const engine = new Server(); + + return setup(engine, 2, async (port, partialDone) => { + engine.on("connection_error", (err) => { + assertExists(err.req); + assertEquals(err.code, 3); + assertEquals(err.message, "Bad request"); + assertEquals(err.context.name, "TRANSPORT_MISMATCH"); + assertEquals(err.context.transport, "websocket"); + assertEquals(err.context.previousTransport, "polling"); + + partialDone(); + }); + + const response = await fetch( + `http://localhost:${port}/engine.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); + + const body = await response.text(); + const sid = JSON.parse(body.substring(1)).sid; + + let timerId: number | undefined; + + const mismatchResponse = await Promise.race([ + fetch( + `http://localhost:${port}/engine.io/?EIO=4&transport=websocket&sid=${sid}`, + { + method: "get", + }, + ), + new Promise((_, reject) => { + timerId = setTimeout( + () => reject(new Error("request timed out")), + 200, + ); + }), + ]); + + if (timerId !== undefined) { + clearTimeout(timerId); + } + + assertEquals(mismatchResponse.status, 400); + + const mismatchBody = await mismatchResponse.json(); + assertEquals(mismatchBody.code, 3); + assertEquals(mismatchBody.message, "Bad request"); + + partialDone(); + }); + }); }); diff --git a/packages/socket.io/lib/client.ts b/packages/socket.io/lib/client.ts index b4c549d..9dc9e0e 100644 --- a/packages/socket.io/lib/client.ts +++ b/packages/socket.io/lib/client.ts @@ -49,15 +49,7 @@ export class Client< this.decoder = decoder; this.conn = conn; - const url = new URL(req.url); - this.handshake = { - url: url.pathname, - headers: req.headers, - query: url.searchParams, - address: (connInfo.remoteAddr as Deno.NetAddr).hostname, - secure: false, - xdomain: req.headers.has("origin"), - }; + this.handshake = createHandshakeBase(req, connInfo); conn.on("message", (data) => this.decoder.add(data)); conn.on("close", (reason) => this.onclose(reason)); @@ -171,7 +163,7 @@ export class Client< _remove( socket: Socket, ): void { - this.sockets.delete(socket.id); + this.sockets.delete(socket.nsp.name); } private close() { @@ -235,3 +227,20 @@ export class Client< } } } + +export function createHandshakeBase( + req: Request, + connInfo: Deno.ServeHandlerInfo, +): Omit { + const url = new URL(req.url); + const origin = req.headers.get("origin"); + + return { + url: url.pathname, + headers: req.headers, + query: url.searchParams, + address: (connInfo.remoteAddr as Deno.NetAddr).hostname, + secure: url.protocol === "https:", + xdomain: origin !== null && origin !== url.origin, + }; +} diff --git a/packages/socket.io/test/client.test.ts b/packages/socket.io/test/client.test.ts new file mode 100644 index 0000000..142c513 --- /dev/null +++ b/packages/socket.io/test/client.test.ts @@ -0,0 +1,38 @@ +import { assertEquals, describe, it } from "../../../test_deps.ts"; +import { createHandshakeBase } from "../lib/client.ts"; + +describe("client handshake metadata", () => { + it("should derive secure and cross-domain flags from the request URL and origin", () => { + const connInfo = { + remoteAddr: { + transport: "tcp", + hostname: "127.0.0.1", + port: 1234, + }, + } as Deno.ServeHandlerInfo; + + const sameOriginHandshake = createHandshakeBase( + new Request("https://example.com/socket.io/?EIO=4&transport=polling", { + headers: { + origin: "https://example.com", + }, + }), + connInfo, + ); + + assertEquals(sameOriginHandshake.secure, true); + assertEquals(sameOriginHandshake.xdomain, false); + + const crossOriginHandshake = createHandshakeBase( + new Request("https://example.com/socket.io/?EIO=4&transport=polling", { + headers: { + origin: "https://other.example.com", + }, + }), + connInfo, + ); + + assertEquals(crossOriginHandshake.secure, true); + assertEquals(crossOriginHandshake.xdomain, true); + }); +}); diff --git a/packages/socket.io/test/handshake.test.ts b/packages/socket.io/test/handshake.test.ts index 61fee3f..24a962e 100644 --- a/packages/socket.io/test/handshake.test.ts +++ b/packages/socket.io/test/handshake.test.ts @@ -143,124 +143,156 @@ describe("handshake", () => { partialDone(); }, ); - }); - it("should trigger a connection event (custom namespace)", () => { - const io = new Server(); + it("should reconnect to a namespace after a client-side namespace disconnect", () => { + const io = new Server(); + io.of("/custom"); + + return setup( + io, + 1, + async (port, done) => { + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); - return setup( - io, - 2, - async (port, partialDone) => { - io.of("/custom").on("connection", (socket) => { - assertExists(socket.id); - partialDone(); - }); + assertEquals(response.status, 200); - const response = await fetch( - `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, - { - method: "get", - }, - ); + const sid = await parseSessionID(response); - assertEquals(response.status, 200); + await eioPush(port, sid, "40/custom,"); + const firstConnectBody = await eioPoll(port, sid); + assertEquals(firstConnectBody.startsWith("40/custom,{"), true); - const sid = await parseSessionID(response); + await eioPush(port, sid, "41/custom,"); - await eioPush(port, sid, "40/custom,"); + await eioPush(port, sid, "40/custom,"); + const secondConnectBody = await eioPoll(port, sid); + assertEquals(secondConnectBody.startsWith("40/custom,{"), true); - const body = await eioPoll(port, sid); - assertEquals(body.startsWith("40/custom,{"), true); + done(); + }); + }); - partialDone(); - }, - ); - }); + it("should trigger a connection event (custom namespace)", () => { + const io = new Server(); - it("should trigger a connection event (dynamic namespace)", () => { - const io = new Server(); + return setup(io, + 2, + async (port, partialDone) => { + io.of("/custom").on("connection", (socket) => { + assertExists(socket.id); + partialDone(); + }); - return setup( - io, - 2, - async (port, partialDone) => { - io.of(/^\/dynamic-\d+$/).on("connection", (socket) => { - assertExists(socket.id); - partialDone(); - }); + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); - const response = await fetch( - `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, - { - method: "get", - }, - ); + assertEquals(response.status, 200); - assertEquals(response.status, 200); + const sid = await parseSessionID(response); - const sid = await parseSessionID(response); + await eioPush(port, sid, "40/custom,"); - await eioPush(port, sid, "40/dynamic-101,"); + const body = await eioPoll(port, sid); + assertEquals(body.startsWith("40/custom,{"), true); - const body = await eioPoll(port, sid); - assertEquals(body.startsWith("40/dynamic-101,{"), true); + partialDone(); + }, + ); + }); + + it("should trigger a connection event (dynamic namespace)", () => { + const io = new Server(); + + return setup( + io, + 2, + async (port, partialDone) => { + io.of(/^\/dynamic-\d+$/).on("connection", (socket) => { + assertExists(socket.id); + partialDone(); + }); - partialDone(); - }, - ); - }); + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); - it("should return an error when reaching a non-existent namespace", () => { - const io = new Server(); + assertEquals(response.status, 200); - return setup( - io, - 1, - async (port, done) => { - const response = await fetch( - `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, - { - method: "get", - }, - ); + const sid = await parseSessionID(response); - const sid = await parseSessionID(response); + await eioPush(port, sid, "40/dynamic-101,"); - await eioPush(port, sid, "40/unknown,"); + const body = await eioPoll(port, sid); + assertEquals(body.startsWith("40/dynamic-101,{"), true); - const body = await eioPoll(port, sid); + partialDone(); + }, + ); + }); + + it("should return an error when reaching a non-existent namespace", () => { + const io = new Server(); + + return setup( + io, + 1, + async (port, done) => { + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); - assertEquals(body, '44/unknown,{"message":"Invalid namespace"}'); + const sid = await parseSessionID(response); - done(); - }, - ); - }); + await eioPush(port, sid, "40/unknown,"); - it("should complete handshake before sending any event", () => { - const io = new Server(); + const body = await eioPoll(port, sid); - return setup( - io, - 1, - async (port, done) => { - io.use((socket) => { - socket.emit("1"); - io.emit("ignored"); // socket is not connected yet - return Promise.resolve(); - }); + assertEquals(body, '44/unknown,{"message":"Invalid namespace"}'); - io.on("connection", (socket) => { - socket.emit("2"); - }); + done(); + }, + ); + }); - const [_, firstPacket] = await runHandshake(port); + it("should complete handshake before sending any event", () => { + const io = new Server(); - assertEquals(firstPacket, '42["1"]\x1e42["2"]'); + return setup( + io, + 1, + async (port, done) => { + io.use((socket) => { + socket.emit("1"); + io.emit("ignored"); // socket is not connected yet + return Promise.resolve(); + }); - done(); - }, - ); - }); -}); + io.on("connection", (socket) => { + socket.emit("2"); + }); + + const [_, firstPacket] = await runHandshake(port); + + assertEquals(firstPacket, '42["1"]\x1e42["2"]'); + + done(); + }, + ); + }); + }) +}) From 2576017ed51cfc5b102a25cc9650be708230c559 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Wed, 25 Mar 2026 19:08:39 +0600 Subject: [PATCH 2/3] Update Formatting --- packages/socket.io/test/handshake.test.ts | 46 +++++++++++------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/socket.io/test/handshake.test.ts b/packages/socket.io/test/handshake.test.ts index 24a962e..a0abfde 100644 --- a/packages/socket.io/test/handshake.test.ts +++ b/packages/socket.io/test/handshake.test.ts @@ -174,39 +174,37 @@ describe("handshake", () => { assertEquals(secondConnectBody.startsWith("40/custom,{"), true); done(); - }); + }, + ); }); it("should trigger a connection event (custom namespace)", () => { const io = new Server(); - return setup(io, - 2, - async (port, partialDone) => { - io.of("/custom").on("connection", (socket) => { - assertExists(socket.id); - partialDone(); - }); + return setup(io, 2, async (port, partialDone) => { + io.of("/custom").on("connection", (socket) => { + assertExists(socket.id); + partialDone(); + }); - const response = await fetch( - `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, - { - method: "get", - }, - ); + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); - assertEquals(response.status, 200); + assertEquals(response.status, 200); - const sid = await parseSessionID(response); + const sid = await parseSessionID(response); - await eioPush(port, sid, "40/custom,"); + await eioPush(port, sid, "40/custom,"); - const body = await eioPoll(port, sid); - assertEquals(body.startsWith("40/custom,{"), true); + const body = await eioPoll(port, sid); + assertEquals(body.startsWith("40/custom,{"), true); - partialDone(); - }, - ); + partialDone(); + }); }); it("should trigger a connection event (dynamic namespace)", () => { @@ -294,5 +292,5 @@ describe("handshake", () => { }, ); }); - }) -}) + }); +}); From 018b3ee085e788614577daf7d3dc14dc9d0cd726 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Wed, 25 Mar 2026 19:17:17 +0600 Subject: [PATCH 3/3] reformat and lint --- packages/socket.io/test/handshake.test.ts | 206 +++++++++++----------- 1 file changed, 101 insertions(+), 105 deletions(-) diff --git a/packages/socket.io/test/handshake.test.ts b/packages/socket.io/test/handshake.test.ts index a0abfde..88e27c4 100644 --- a/packages/socket.io/test/handshake.test.ts +++ b/packages/socket.io/test/handshake.test.ts @@ -143,46 +143,75 @@ describe("handshake", () => { partialDone(); }, ); + }); - it("should reconnect to a namespace after a client-side namespace disconnect", () => { - const io = new Server(); - io.of("/custom"); - - return setup( - io, - 1, - async (port, done) => { - const response = await fetch( - `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, - { - method: "get", - }, - ); + it("should reconnect to a namespace after a client-side namespace disconnect", () => { + const io = new Server(); + io.of("/custom"); + + return setup(io, 1, async (port, done) => { + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); - assertEquals(response.status, 200); + assertEquals(response.status, 200); - const sid = await parseSessionID(response); + const sid = await parseSessionID(response); - await eioPush(port, sid, "40/custom,"); - const firstConnectBody = await eioPoll(port, sid); - assertEquals(firstConnectBody.startsWith("40/custom,{"), true); + await eioPush(port, sid, "40/custom,"); + const firstConnectBody = await eioPoll(port, sid); + assertEquals(firstConnectBody.startsWith("40/custom,{"), true); - await eioPush(port, sid, "41/custom,"); + await eioPush(port, sid, "41/custom,"); - await eioPush(port, sid, "40/custom,"); - const secondConnectBody = await eioPoll(port, sid); - assertEquals(secondConnectBody.startsWith("40/custom,{"), true); + await eioPush(port, sid, "40/custom,"); + const secondConnectBody = await eioPoll(port, sid); + assertEquals(secondConnectBody.startsWith("40/custom,{"), true); - done(); + done(); + }); + }); + + it("should trigger a connection event (custom namespace)", () => { + const io = new Server(); + + return setup(io, 2, async (port, partialDone) => { + io.of("/custom").on("connection", (socket) => { + assertExists(socket.id); + partialDone(); + }); + + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", }, ); + + assertEquals(response.status, 200); + + const sid = await parseSessionID(response); + + await eioPush(port, sid, "40/custom,"); + + const body = await eioPoll(port, sid); + assertEquals(body.startsWith("40/custom,{"), true); + + partialDone(); }); + }); - it("should trigger a connection event (custom namespace)", () => { - const io = new Server(); + it("should trigger a connection event (dynamic namespace)", () => { + const io = new Server(); - return setup(io, 2, async (port, partialDone) => { - io.of("/custom").on("connection", (socket) => { + return setup( + io, + 2, + async (port, partialDone) => { + io.of(/^\/dynamic-\d+$/).on("connection", (socket) => { assertExists(socket.id); partialDone(); }); @@ -198,99 +227,66 @@ describe("handshake", () => { const sid = await parseSessionID(response); - await eioPush(port, sid, "40/custom,"); + await eioPush(port, sid, "40/dynamic-101,"); const body = await eioPoll(port, sid); - assertEquals(body.startsWith("40/custom,{"), true); + assertEquals(body.startsWith("40/dynamic-101,{"), true); partialDone(); - }); - }); - - it("should trigger a connection event (dynamic namespace)", () => { - const io = new Server(); - - return setup( - io, - 2, - async (port, partialDone) => { - io.of(/^\/dynamic-\d+$/).on("connection", (socket) => { - assertExists(socket.id); - partialDone(); - }); - - const response = await fetch( - `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, - { - method: "get", - }, - ); - - assertEquals(response.status, 200); - - const sid = await parseSessionID(response); - - await eioPush(port, sid, "40/dynamic-101,"); + }, + ); + }); - const body = await eioPoll(port, sid); - assertEquals(body.startsWith("40/dynamic-101,{"), true); + it("should return an error when reaching a non-existent namespace", () => { + const io = new Server(); - partialDone(); - }, - ); - }); + return setup( + io, + 1, + async (port, done) => { + const response = await fetch( + `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, + { + method: "get", + }, + ); - it("should return an error when reaching a non-existent namespace", () => { - const io = new Server(); - - return setup( - io, - 1, - async (port, done) => { - const response = await fetch( - `http://localhost:${port}/socket.io/?EIO=4&transport=polling`, - { - method: "get", - }, - ); + const sid = await parseSessionID(response); - const sid = await parseSessionID(response); + await eioPush(port, sid, "40/unknown,"); - await eioPush(port, sid, "40/unknown,"); + const body = await eioPoll(port, sid); - const body = await eioPoll(port, sid); + assertEquals(body, '44/unknown,{"message":"Invalid namespace"}'); - assertEquals(body, '44/unknown,{"message":"Invalid namespace"}'); + done(); + }, + ); + }); - done(); - }, - ); - }); + it("should complete handshake before sending any event", () => { + const io = new Server(); - it("should complete handshake before sending any event", () => { - const io = new Server(); - - return setup( - io, - 1, - async (port, done) => { - io.use((socket) => { - socket.emit("1"); - io.emit("ignored"); // socket is not connected yet - return Promise.resolve(); - }); + return setup( + io, + 1, + async (port, done) => { + io.use((socket) => { + socket.emit("1"); + io.emit("ignored"); // socket is not connected yet + return Promise.resolve(); + }); - io.on("connection", (socket) => { - socket.emit("2"); - }); + io.on("connection", (socket) => { + socket.emit("2"); + }); - const [_, firstPacket] = await runHandshake(port); + const [_, firstPacket] = await runHandshake(port); - assertEquals(firstPacket, '42["1"]\x1e42["2"]'); + assertEquals(firstPacket, '42["1"]\x1e42["2"]'); - done(); - }, - ); - }); + done(); + }, + ); }); });