Skip to content

Commit 5fc88a6

Browse files
feat: implement cookie management for the Node.js client
When setting the `withCredentials` option to `true`, the Node.js client will now include the cookies in the HTTP requests, making it easier to use it with cookie-based sticky sessions. Related: socketio/socket.io#3812
1 parent 7195c0f commit 5fc88a6

File tree

5 files changed

+219
-3
lines changed

5 files changed

+219
-3
lines changed

Diff for: lib/transports/polling.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import debugModule from "debug"; // debug()
33
import { yeast } from "../contrib/yeast.js";
44
import { encode } from "../contrib/parseqs.js";
55
import { encodePayload, decodePayload, RawData } from "engine.io-parser";
6-
import { XHR as XMLHttpRequest } from "./xmlhttprequest.js";
6+
import {
7+
CookieJar,
8+
createCookieJar,
9+
XHR as XMLHttpRequest,
10+
} from "./xmlhttprequest.js";
711
import { Emitter } from "@socket.io/component-emitter";
812
import { SocketOptions } from "../socket.js";
913
import { installTimerFunctions, pick } from "../util.js";
@@ -26,6 +30,7 @@ export class Polling extends Transport {
2630

2731
private polling: boolean = false;
2832
private pollXhr: any;
33+
private cookieJar?: CookieJar;
2934

3035
/**
3136
* XHR Polling constructor.
@@ -56,6 +61,10 @@ export class Polling extends Transport {
5661
*/
5762
const forceBase64 = opts && opts.forceBase64;
5863
this.supportsBinary = hasXHR2 && !forceBase64;
64+
65+
if (this.opts.withCredentials) {
66+
this.cookieJar = createCookieJar();
67+
}
5968
}
6069

6170
override get name() {
@@ -251,7 +260,11 @@ export class Polling extends Transport {
251260
* @private
252261
*/
253262
request(opts = {}) {
254-
Object.assign(opts, { xd: this.xd, xs: this.xs }, this.opts);
263+
Object.assign(
264+
opts,
265+
{ xd: this.xd, xs: this.xs, cookieJar: this.cookieJar },
266+
this.opts
267+
);
255268
return new Request(this.uri(), opts);
256269
}
257270

@@ -296,7 +309,7 @@ interface RequestReservedEvents {
296309
}
297310

298311
export class Request extends Emitter<{}, {}, RequestReservedEvents> {
299-
private readonly opts: { xd; xs } & SocketOptions;
312+
private readonly opts: { xd; xs; cookieJar: CookieJar } & SocketOptions;
300313
private readonly method: string;
301314
private readonly uri: string;
302315
private readonly async: boolean;
@@ -375,6 +388,8 @@ export class Request extends Emitter<{}, {}, RequestReservedEvents> {
375388
xhr.setRequestHeader("Accept", "*/*");
376389
} catch (e) {}
377390

391+
this.opts.cookieJar?.addCookies(xhr);
392+
378393
// ie6 check
379394
if ("withCredentials" in xhr) {
380395
xhr.withCredentials = this.opts.withCredentials;
@@ -385,6 +400,10 @@ export class Request extends Emitter<{}, {}, RequestReservedEvents> {
385400
}
386401

387402
xhr.onreadystatechange = () => {
403+
if (xhr.readyState === 3) {
404+
this.opts.cookieJar?.parseCookies(xhr);
405+
}
406+
388407
if (4 !== xhr.readyState) return;
389408
if (200 === xhr.status || 1223 === xhr.status) {
390409
this.onLoad();

Diff for: lib/transports/xmlhttprequest.browser.ts

+2
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ export function XHR(opts) {
2121
} catch (e) {}
2222
}
2323
}
24+
25+
export function createCookieJar() {}

Diff for: lib/transports/xmlhttprequest.ts

+99
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,102 @@
11
import * as XMLHttpRequestModule from "xmlhttprequest-ssl";
22

33
export const XHR = XMLHttpRequestModule.default || XMLHttpRequestModule;
4+
5+
export function createCookieJar() {
6+
return new CookieJar();
7+
}
8+
9+
interface Cookie {
10+
name: string;
11+
value: string;
12+
expires?: Date;
13+
}
14+
15+
/**
16+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
17+
*/
18+
export function parse(setCookieString: string): Cookie {
19+
const parts = setCookieString.split("; ");
20+
const i = parts[0].indexOf("=");
21+
22+
if (i === -1) {
23+
return;
24+
}
25+
26+
const name = parts[0].substring(0, i).trim();
27+
28+
if (!name.length) {
29+
return;
30+
}
31+
32+
let value = parts[0].substring(i + 1).trim();
33+
34+
if (value.charCodeAt(0) === 0x22) {
35+
// remove double quotes
36+
value = value.slice(1, -1);
37+
}
38+
39+
const cookie: Cookie = {
40+
name,
41+
value,
42+
};
43+
44+
for (let j = 1; j < parts.length; j++) {
45+
const subParts = parts[j].split("=");
46+
if (subParts.length !== 2) {
47+
continue;
48+
}
49+
const key = subParts[0].trim();
50+
const value = subParts[1].trim();
51+
switch (key) {
52+
case "Expires":
53+
cookie.expires = new Date(value);
54+
break;
55+
case "Max-Age":
56+
const expiration = new Date();
57+
expiration.setUTCSeconds(
58+
expiration.getUTCSeconds() + parseInt(value, 10)
59+
);
60+
cookie.expires = expiration;
61+
break;
62+
default:
63+
// ignore other keys
64+
}
65+
}
66+
67+
return cookie;
68+
}
69+
70+
export class CookieJar {
71+
private cookies = new Map<string, Cookie>();
72+
73+
public parseCookies(xhr: any) {
74+
const values = xhr.getResponseHeader("set-cookie");
75+
if (!values) {
76+
return;
77+
}
78+
values.forEach((value) => {
79+
const parsed = parse(value);
80+
if (parsed) {
81+
this.cookies.set(parsed.name, parsed);
82+
}
83+
});
84+
}
85+
86+
public addCookies(xhr: any) {
87+
const cookies = [];
88+
89+
this.cookies.forEach((cookie, name) => {
90+
if (cookie.expires?.getTime() < Date.now()) {
91+
this.cookies.delete(name);
92+
} else {
93+
cookies.push(`${name}=${cookie.value}`);
94+
}
95+
});
96+
97+
if (cookies.length) {
98+
xhr.setDisableHeaderCheck(true);
99+
xhr.setRequestHeader("cookie", cookies.join("; "));
100+
}
101+
}
102+
}

Diff for: test/node.js

+78
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ const path = require("path");
22
const { exec } = require("child_process");
33
const { Socket } = require("../");
44
const { repeat } = require("./util");
5+
const expect = require("expect.js");
6+
const { parse } = require("../build/cjs/transports/xmlhttprequest.js");
57

68
describe("node.js", () => {
79
describe("autoRef option", () => {
@@ -55,4 +57,80 @@ describe("node.js", () => {
5557
});
5658
});
5759
});
60+
61+
it("should send cookies with withCredentials: true", (done) => {
62+
const socket = new Socket("http://localhost:3000", {
63+
transports: ["polling"],
64+
withCredentials: true,
65+
});
66+
67+
socket.on("open", () => {
68+
socket.send("sendHeaders");
69+
});
70+
71+
socket.on("message", (data) => {
72+
if (data === "hi") {
73+
return;
74+
}
75+
const headers = JSON.parse(data);
76+
expect(headers.cookie).to.eql("1=1; 2=2");
77+
78+
socket.close();
79+
done();
80+
});
81+
});
82+
83+
it("should not send cookies with withCredentials: false", (done) => {
84+
const socket = new Socket("http://localhost:3000", {
85+
transports: ["polling"],
86+
withCredentials: false,
87+
});
88+
89+
socket.on("open", () => {
90+
socket.send("sendHeaders");
91+
});
92+
93+
socket.on("message", (data) => {
94+
if (data === "hi") {
95+
return;
96+
}
97+
const headers = JSON.parse(data);
98+
expect(headers.cookie).to.eql(undefined);
99+
100+
socket.close();
101+
done();
102+
});
103+
});
104+
});
105+
106+
describe("cookie parsing", () => {
107+
it("should parse a simple set-cookie header", () => {
108+
const cookieStr = "foo=bar";
109+
110+
expect(parse(cookieStr)).to.eql({
111+
name: "foo",
112+
value: "bar",
113+
});
114+
});
115+
116+
it("should parse a complex set-cookie header", () => {
117+
const cookieStr =
118+
"foo=bar; Max-Age=1000; Domain=.example.com; Path=/; Expires=Tue, 01 Jul 2025 10:01:11 GMT; HttpOnly; Secure; SameSite=strict";
119+
120+
expect(parse(cookieStr)).to.eql({
121+
name: "foo",
122+
value: "bar",
123+
expires: new Date("Tue Jul 01 2025 06:01:11 GMT-0400 (EDT)"),
124+
});
125+
});
126+
127+
it("should parse a weird but valid cookie", () => {
128+
const cookieStr =
129+
"foo=bar=bar&foo=foo&John=Doe&Doe=John; Domain=.example.com; Path=/; HttpOnly; Secure";
130+
131+
expect(parse(cookieStr)).to.eql({
132+
name: "foo",
133+
value: "bar=bar&foo=foo&John=Doe&Doe=John",
134+
});
135+
});
58136
});

Diff for: test/support/hooks.js

+18
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { attach } = require("engine.io");
77
const { rollup } = require("rollup");
88

99
const rollupConfig = require("../../support/rollup.config.umd.js");
10+
const { serialize } = require("cookie");
1011

1112
let httpServer, engine;
1213

@@ -50,11 +51,28 @@ exports.mochaHooks = {
5051
} else if (data === "give utf8") {
5152
socket.send("пойду спать всем спокойной ночи");
5253
return;
54+
} else if (data === "sendHeaders") {
55+
const headers = socket.transport?.dataReq?.headers;
56+
return socket.send(JSON.stringify(headers));
5357
}
5458

5559
socket.send(data);
5660
});
5761
});
62+
63+
engine.on("initial_headers", (headers) => {
64+
headers["set-cookie"] = [
65+
serialize("1", "1", { maxAge: 86400 }),
66+
serialize("2", "2", {
67+
sameSite: true,
68+
path: "/",
69+
httpOnly: true,
70+
secure: true,
71+
}),
72+
serialize("3", "3", { maxAge: 0 }),
73+
serialize("4", "4", { expires: new Date() }),
74+
];
75+
});
5876
},
5977

6078
afterAll() {

0 commit comments

Comments
 (0)