@multiplatform.one/frappe 9.1.0 → 9.3.0
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/dist/cjs/auth.cjs +5 -1
- package/dist/cjs/auth.native.js +5 -1
- package/dist/cjs/auth.native.js.map +1 -1
- package/dist/cjs/core/http.cjs +21 -6
- package/dist/cjs/core/http.native.js +27 -6
- package/dist/cjs/core/http.native.js.map +1 -1
- package/dist/cjs/core/sessionGate.cjs +121 -0
- package/dist/cjs/core/sessionGate.native.js +284 -0
- package/dist/cjs/core/sessionGate.native.js.map +1 -0
- package/dist/cjs/sync/realtime.cjs +2 -1
- package/dist/cjs/sync/realtime.native.js +2 -1
- package/dist/cjs/sync/realtime.native.js.map +1 -1
- package/dist/esm/auth.mjs +5 -1
- package/dist/esm/auth.mjs.map +1 -1
- package/dist/esm/auth.native.js +5 -1
- package/dist/esm/auth.native.js.map +1 -1
- package/dist/esm/core/http.mjs +21 -6
- package/dist/esm/core/http.mjs.map +1 -1
- package/dist/esm/core/http.native.js +27 -6
- package/dist/esm/core/http.native.js.map +1 -1
- package/dist/esm/core/sessionGate.mjs +94 -0
- package/dist/esm/core/sessionGate.mjs.map +1 -0
- package/dist/esm/core/sessionGate.native.js +254 -0
- package/dist/esm/core/sessionGate.native.js.map +1 -0
- package/dist/esm/sync/realtime.mjs +2 -1
- package/dist/esm/sync/realtime.mjs.map +1 -1
- package/dist/esm/sync/realtime.native.js +2 -1
- package/dist/esm/sync/realtime.native.js.map +1 -1
- package/dist/jsx/auth.mjs +5 -1
- package/dist/jsx/auth.mjs.map +1 -1
- package/dist/jsx/auth.native.js +5 -1
- package/dist/jsx/auth.native.js.map +1 -1
- package/dist/jsx/core/http.mjs +21 -6
- package/dist/jsx/core/http.mjs.map +1 -1
- package/dist/jsx/core/http.native.js +27 -6
- package/dist/jsx/core/http.native.js.map +1 -1
- package/dist/jsx/core/sessionGate.mjs +94 -0
- package/dist/jsx/core/sessionGate.mjs.map +1 -0
- package/dist/jsx/core/sessionGate.native.js +284 -0
- package/dist/jsx/core/sessionGate.native.js.map +1 -0
- package/dist/jsx/sync/realtime.mjs +2 -1
- package/dist/jsx/sync/realtime.mjs.map +1 -1
- package/dist/jsx/sync/realtime.native.js +2 -1
- package/dist/jsx/sync/realtime.native.js.map +1 -1
- package/package.json +7 -7
- package/src/auth.sessionRace.spec.ts +209 -0
- package/src/auth.spec.ts +33 -14
- package/src/auth.ts +7 -5
- package/src/core/http.spec.ts +16 -10
- package/src/core/http.ts +28 -6
- package/src/core/sessionGate.ts +140 -0
- package/src/sync/realtime.spec.ts +23 -4
- package/src/sync/realtime.ts +2 -1
- package/types/auth.d.ts.map +1 -1
- package/types/core/http.d.ts +5 -1
- package/types/core/http.d.ts.map +1 -1
- package/types/core/sessionGate.d.ts +29 -0
- package/types/core/sessionGate.d.ts.map +1 -0
- package/types/sync/realtime.d.ts.map +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AuthModule } from "./auth";
|
|
3
|
+
import { HttpClient } from "./core/http";
|
|
4
|
+
import { SESSION_CHANGE_WAIT_MS } from "./core/sessionGate";
|
|
5
|
+
|
|
6
|
+
const baseURL = "http://frappe.test";
|
|
7
|
+
const signedInUser = "user@example.test";
|
|
8
|
+
const fixtureSid = "fixture-session";
|
|
9
|
+
|
|
10
|
+
function deferred() {
|
|
11
|
+
let resolve: () => void = () => {};
|
|
12
|
+
const promise = new Promise<void>((r) => {
|
|
13
|
+
resolve = r;
|
|
14
|
+
});
|
|
15
|
+
return { promise, resolve };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function json(status: number, body: unknown): Response {
|
|
19
|
+
return new Response(JSON.stringify(body), {
|
|
20
|
+
status,
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function flush() {
|
|
26
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A fake bench with a cookie jar. Like Frappe, every response but the login's
|
|
31
|
+
* sets `sid` to the session the request ran under, and the jar takes that
|
|
32
|
+
* value when the response lands, the way a browser applies Set-Cookie.
|
|
33
|
+
*/
|
|
34
|
+
function fakeBench() {
|
|
35
|
+
const jar = { sid: "Guest" };
|
|
36
|
+
const guestCount = deferred();
|
|
37
|
+
const loginResponse = { gate: Promise.resolve() };
|
|
38
|
+
const sent: string[] = [];
|
|
39
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
40
|
+
const path = new URL(String(input)).pathname;
|
|
41
|
+
sent.push(path);
|
|
42
|
+
if (path.startsWith("/api/method/hang") && sent.filter((p) => p === path).length === 1) {
|
|
43
|
+
return new Promise<Response>((_, reject) => {
|
|
44
|
+
init?.signal?.addEventListener("abort", () => {
|
|
45
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (path.startsWith("/api/method/hang")) {
|
|
50
|
+
const ranAs = jar.sid;
|
|
51
|
+
jar.sid = ranAs;
|
|
52
|
+
return json(200, { message: ranAs });
|
|
53
|
+
}
|
|
54
|
+
if (path === "/api/method/frappe.client.get_count") {
|
|
55
|
+
const ranAs = jar.sid;
|
|
56
|
+
await guestCount.promise;
|
|
57
|
+
jar.sid = ranAs;
|
|
58
|
+
return json(200, { message: 3 });
|
|
59
|
+
}
|
|
60
|
+
if (path === "/api/method/login") {
|
|
61
|
+
await loginResponse.gate;
|
|
62
|
+
jar.sid = fixtureSid;
|
|
63
|
+
return json(200, { message: "Logged In" });
|
|
64
|
+
}
|
|
65
|
+
if (path === "/api/method/frappe.auth.get_logged_user") {
|
|
66
|
+
if (jar.sid !== fixtureSid) return json(403, { message: "Not permitted" });
|
|
67
|
+
return json(200, { message: signedInUser });
|
|
68
|
+
}
|
|
69
|
+
if (path === "/api/method/logout") {
|
|
70
|
+
jar.sid = "Guest";
|
|
71
|
+
return json(200, {});
|
|
72
|
+
}
|
|
73
|
+
return json(404, {});
|
|
74
|
+
});
|
|
75
|
+
return { jar, guestCount, loginResponse, sent, fetchMock };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("login against a guest request still in flight (FZ-10892)", () => {
|
|
79
|
+
let bench: ReturnType<typeof fakeBench>;
|
|
80
|
+
const realFetch = globalThis.fetch;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
bench = fakeBench();
|
|
84
|
+
globalThis.fetch = bench.fetchMock as unknown as typeof fetch;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
globalThis.fetch = realFetch;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps the new session when the guest response lands after the login was issued", async () => {
|
|
92
|
+
const tiles = new HttpClient(baseURL);
|
|
93
|
+
const auth = new AuthModule(new HttpClient(baseURL));
|
|
94
|
+
|
|
95
|
+
const guest = tiles.get("/api/method/frappe.client.get_count", { doctype: "ToDo" });
|
|
96
|
+
await flush();
|
|
97
|
+
const login = auth.loginWithUsernamePassword({ username: signedInUser, password: "x" });
|
|
98
|
+
await flush();
|
|
99
|
+
bench.guestCount.resolve();
|
|
100
|
+
await Promise.all([guest, login]);
|
|
101
|
+
|
|
102
|
+
expect(bench.jar.sid).toBe(fixtureSid);
|
|
103
|
+
await expect(auth.getLoggedInUser()).resolves.toBe(signedInUser);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("sends the login only after the guest request on the wire has its response", async () => {
|
|
107
|
+
const tiles = new HttpClient(baseURL);
|
|
108
|
+
const auth = new AuthModule(new HttpClient(baseURL));
|
|
109
|
+
|
|
110
|
+
const guest = tiles.get("/api/method/frappe.client.get_count", { doctype: "ToDo" });
|
|
111
|
+
await flush();
|
|
112
|
+
const login = auth.loginWithUsernamePassword({ username: signedInUser, password: "x" });
|
|
113
|
+
await flush();
|
|
114
|
+
expect(bench.sent).toEqual(["/api/method/frappe.client.get_count"]);
|
|
115
|
+
|
|
116
|
+
bench.guestCount.resolve();
|
|
117
|
+
await Promise.all([guest, login]);
|
|
118
|
+
expect(bench.sent).toEqual(["/api/method/frappe.client.get_count", "/api/method/login"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("holds a request issued during the login until the login response lands", async () => {
|
|
122
|
+
const loginGate = deferred();
|
|
123
|
+
bench.loginResponse.gate = loginGate.promise;
|
|
124
|
+
const auth = new AuthModule(new HttpClient(baseURL));
|
|
125
|
+
|
|
126
|
+
const login = auth.loginWithUsernamePassword({ username: signedInUser, password: "x" });
|
|
127
|
+
await flush();
|
|
128
|
+
const whoami = auth.getLoggedInUser();
|
|
129
|
+
await flush();
|
|
130
|
+
expect(bench.sent).toEqual(["/api/method/login"]);
|
|
131
|
+
|
|
132
|
+
loginGate.resolve();
|
|
133
|
+
await login;
|
|
134
|
+
await expect(whoami).resolves.toBe(signedInUser);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("leaves token-auth requests ungated, since they carry no cookie", async () => {
|
|
138
|
+
const tokenClient = new HttpClient(baseURL, { useToken: true, token: "key:secret" });
|
|
139
|
+
const auth = new AuthModule(new HttpClient(baseURL));
|
|
140
|
+
|
|
141
|
+
const loginGate = deferred();
|
|
142
|
+
bench.loginResponse.gate = loginGate.promise;
|
|
143
|
+
const login = auth.loginWithUsernamePassword({ username: signedInUser, password: "x" });
|
|
144
|
+
await flush();
|
|
145
|
+
const tokenRead = tokenClient.get("/api/method/frappe.client.get_count", { doctype: "ToDo" });
|
|
146
|
+
await flush();
|
|
147
|
+
expect(bench.sent).toContain("/api/method/frappe.client.get_count");
|
|
148
|
+
|
|
149
|
+
bench.guestCount.resolve();
|
|
150
|
+
loginGate.resolve();
|
|
151
|
+
await Promise.all([login, tokenRead]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("a session change against a request that never answers (FZ-10892)", () => {
|
|
156
|
+
let bench: ReturnType<typeof fakeBench>;
|
|
157
|
+
const realFetch = globalThis.fetch;
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
161
|
+
bench = fakeBench();
|
|
162
|
+
globalThis.fetch = bench.fetchMock as unknown as typeof fetch;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
vi.useRealTimers();
|
|
167
|
+
globalThis.fetch = realFetch;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("completes the login once the wait cap passes, and replays the hung read under the new session", async () => {
|
|
171
|
+
const tiles = new HttpClient(baseURL);
|
|
172
|
+
const auth = new AuthModule(new HttpClient(baseURL));
|
|
173
|
+
|
|
174
|
+
const hung = tiles.get<{ message: string }>("/api/method/hang.get_count");
|
|
175
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
176
|
+
const login = auth.loginWithUsernamePassword({ username: signedInUser, password: "x" });
|
|
177
|
+
await vi.advanceTimersByTimeAsync(SESSION_CHANGE_WAIT_MS - 1);
|
|
178
|
+
expect(bench.sent).toEqual(["/api/method/hang.get_count"]);
|
|
179
|
+
|
|
180
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
181
|
+
await login;
|
|
182
|
+
expect(bench.jar.sid).toBe(fixtureSid);
|
|
183
|
+
expect(bench.fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
|
184
|
+
|
|
185
|
+
await expect(hung).resolves.toMatchObject({ payload: { message: fixtureSid } });
|
|
186
|
+
expect(bench.sent).toEqual([
|
|
187
|
+
"/api/method/hang.get_count",
|
|
188
|
+
"/api/method/login",
|
|
189
|
+
"/api/method/hang.get_count",
|
|
190
|
+
]);
|
|
191
|
+
await expect(auth.getLoggedInUser()).resolves.toBe(signedInUser);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("rejects a hung write it had to drop instead of sending it again", async () => {
|
|
195
|
+
const tiles = new HttpClient(baseURL);
|
|
196
|
+
const auth = new AuthModule(new HttpClient(baseURL));
|
|
197
|
+
|
|
198
|
+
const hung = tiles.post("/api/method/hang.save", { value: 1 });
|
|
199
|
+
const rejected = expect(hung).rejects.toMatchObject({ name: "AbortError" });
|
|
200
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
201
|
+
const login = auth.loginWithUsernamePassword({ username: signedInUser, password: "x" });
|
|
202
|
+
await vi.advanceTimersByTimeAsync(SESSION_CHANGE_WAIT_MS);
|
|
203
|
+
await login;
|
|
204
|
+
|
|
205
|
+
await rejected;
|
|
206
|
+
expect(bench.sent).toEqual(["/api/method/hang.save", "/api/method/login"]);
|
|
207
|
+
expect(bench.jar.sid).toBe(fixtureSid);
|
|
208
|
+
});
|
|
209
|
+
});
|
package/src/auth.spec.ts
CHANGED
|
@@ -81,10 +81,15 @@ describe("AuthModule", () => {
|
|
|
81
81
|
};
|
|
82
82
|
(mockHttpClient.post as any).mockResolvedValue(mockResponse);
|
|
83
83
|
const result = await auth.loginWithUsernamePassword(loginData);
|
|
84
|
-
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
85
|
+
"/api/method/login",
|
|
86
|
+
{
|
|
87
|
+
usr: "testuser",
|
|
88
|
+
pwd: "testpass123",
|
|
89
|
+
},
|
|
90
|
+
undefined,
|
|
91
|
+
{ sessionChange: true },
|
|
92
|
+
);
|
|
88
93
|
expect(result).toEqual(mockResponse);
|
|
89
94
|
});
|
|
90
95
|
|
|
@@ -102,10 +107,15 @@ describe("AuthModule", () => {
|
|
|
102
107
|
};
|
|
103
108
|
(mockHttpClient.post as any).mockResolvedValue(mockResponse);
|
|
104
109
|
await auth.loginWithUsernamePassword(loginData);
|
|
105
|
-
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
111
|
+
"/api/method/login",
|
|
112
|
+
{
|
|
113
|
+
usr: "test@example.com",
|
|
114
|
+
pwd: "securePassword!",
|
|
115
|
+
},
|
|
116
|
+
undefined,
|
|
117
|
+
{ sessionChange: true },
|
|
118
|
+
);
|
|
109
119
|
});
|
|
110
120
|
|
|
111
121
|
it("should handle login with special characters in password", async () => {
|
|
@@ -122,10 +132,15 @@ describe("AuthModule", () => {
|
|
|
122
132
|
};
|
|
123
133
|
(mockHttpClient.post as any).mockResolvedValue(mockResponse);
|
|
124
134
|
await auth.loginWithUsernamePassword(loginData);
|
|
125
|
-
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
135
|
+
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
136
|
+
"/api/method/login",
|
|
137
|
+
{
|
|
138
|
+
usr: loginData.username,
|
|
139
|
+
pwd: loginData.password,
|
|
140
|
+
},
|
|
141
|
+
undefined,
|
|
142
|
+
{ sessionChange: true },
|
|
143
|
+
);
|
|
129
144
|
});
|
|
130
145
|
|
|
131
146
|
it("should handle login error", async () => {
|
|
@@ -203,7 +218,9 @@ describe("AuthModule", () => {
|
|
|
203
218
|
};
|
|
204
219
|
(mockHttpClient.post as any).mockResolvedValue(mockResponse);
|
|
205
220
|
const result = await auth.logout();
|
|
206
|
-
expect(mockHttpClient.post).toHaveBeenCalledWith("/api/method/logout"
|
|
221
|
+
expect(mockHttpClient.post).toHaveBeenCalledWith("/api/method/logout", undefined, undefined, {
|
|
222
|
+
sessionChange: true,
|
|
223
|
+
});
|
|
207
224
|
expect(result).toEqual(mockResponse);
|
|
208
225
|
});
|
|
209
226
|
|
|
@@ -217,7 +234,9 @@ describe("AuthModule", () => {
|
|
|
217
234
|
};
|
|
218
235
|
(mockHttpClient.post as any).mockResolvedValue(mockResponse);
|
|
219
236
|
const result = await auth.logout();
|
|
220
|
-
expect(mockHttpClient.post).toHaveBeenCalledWith("/api/method/logout"
|
|
237
|
+
expect(mockHttpClient.post).toHaveBeenCalledWith("/api/method/logout", undefined, undefined, {
|
|
238
|
+
sessionChange: true,
|
|
239
|
+
});
|
|
221
240
|
expect(result).toEqual(mockResponse);
|
|
222
241
|
});
|
|
223
242
|
|
package/src/auth.ts
CHANGED
|
@@ -8,10 +8,12 @@ export class AuthModule {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
async loginWithUsernamePassword(input: { username: string; password: string }) {
|
|
11
|
-
return this.http.post(
|
|
12
|
-
|
|
13
|
-
pwd: input.password,
|
|
14
|
-
|
|
11
|
+
return this.http.post(
|
|
12
|
+
"/api/method/login",
|
|
13
|
+
{ usr: input.username, pwd: input.password },
|
|
14
|
+
undefined,
|
|
15
|
+
{ sessionChange: true },
|
|
16
|
+
);
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
async getLoggedInUser(): Promise<string> {
|
|
@@ -22,7 +24,7 @@ export class AuthModule {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
async logout() {
|
|
25
|
-
return this.http.post("/api/method/logout");
|
|
27
|
+
return this.http.post("/api/method/logout", undefined, undefined, { sessionChange: true });
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
async forgetPassword(email: string) {
|
package/src/core/http.spec.ts
CHANGED
|
@@ -582,13 +582,16 @@ describe("HttpClient", () => {
|
|
|
582
582
|
status: 200,
|
|
583
583
|
json: async () => ({ success: true }),
|
|
584
584
|
};
|
|
585
|
-
global.fetch = vi.fn().mockResolvedValue(mockRes);
|
|
586
585
|
const controller = new AbortController();
|
|
586
|
+
let abortedInFlight = false;
|
|
587
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
|
588
|
+
controller.abort();
|
|
589
|
+
abortedInFlight = Boolean(init.signal?.aborted);
|
|
590
|
+
return mockRes;
|
|
591
|
+
});
|
|
587
592
|
await client.request(HttpMethod.Get, "/test", { signal: controller.signal });
|
|
588
|
-
expect(fetch).toHaveBeenCalledWith(
|
|
589
|
-
|
|
590
|
-
expect.objectContaining({ signal: controller.signal }),
|
|
591
|
-
);
|
|
593
|
+
expect(fetch).toHaveBeenCalledWith(`${baseURL}/test`, expect.anything());
|
|
594
|
+
expect(abortedInFlight).toBe(true);
|
|
592
595
|
});
|
|
593
596
|
|
|
594
597
|
it("get() forwards the signal", async () => {
|
|
@@ -598,13 +601,16 @@ describe("HttpClient", () => {
|
|
|
598
601
|
status: 200,
|
|
599
602
|
json: async () => ({ success: true }),
|
|
600
603
|
};
|
|
601
|
-
global.fetch = vi.fn().mockResolvedValue(mockRes);
|
|
602
604
|
const controller = new AbortController();
|
|
605
|
+
let abortedInFlight = false;
|
|
606
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
|
607
|
+
controller.abort();
|
|
608
|
+
abortedInFlight = Boolean(init.signal?.aborted);
|
|
609
|
+
return mockRes;
|
|
610
|
+
});
|
|
603
611
|
await client.get("/test", { a: 1 }, undefined, controller.signal);
|
|
604
|
-
expect(fetch).toHaveBeenCalledWith(
|
|
605
|
-
|
|
606
|
-
expect.objectContaining({ signal: controller.signal }),
|
|
607
|
-
);
|
|
612
|
+
expect(fetch).toHaveBeenCalledWith(`${baseURL}/test?a=1`, expect.anything());
|
|
613
|
+
expect(abortedInFlight).toBe(true);
|
|
608
614
|
});
|
|
609
615
|
|
|
610
616
|
it("rejects without retrying when the request is aborted", async () => {
|
package/src/core/http.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getCookie, setCookie, deleteCookie, isNative } from "@multiplatform.one/platform";
|
|
2
2
|
import { isBrowser } from "../util";
|
|
3
3
|
import { readServerMessages } from "./serverMessages";
|
|
4
|
+
import { sendThroughSessionGate } from "./sessionGate";
|
|
4
5
|
import { AuthType, FrappeError, HttpMethod } from "./types";
|
|
5
6
|
import type { AuthConfig, RequestCredentialsMode, RetryConfig } from "./types";
|
|
6
7
|
|
|
@@ -197,7 +198,7 @@ export class HttpClient {
|
|
|
197
198
|
private async fetchNativeCsrfToken(): Promise<string | undefined> {
|
|
198
199
|
try {
|
|
199
200
|
const rpcUrl = this.resolveURL("/api/method/frappe.sessions.get_csrf_token");
|
|
200
|
-
const res = await
|
|
201
|
+
const res = await this.sendGated(rpcUrl, { method: "GET", credentials: "include" });
|
|
201
202
|
if (res.ok) {
|
|
202
203
|
const payload = (await res.json()) as { message?: unknown };
|
|
203
204
|
if (typeof payload?.message === "string" && payload.message) return payload.message;
|
|
@@ -207,7 +208,7 @@ export class HttpClient {
|
|
|
207
208
|
}
|
|
208
209
|
try {
|
|
209
210
|
const appUrl = this.resolveURL("/app");
|
|
210
|
-
const res = await
|
|
211
|
+
const res = await this.sendGated(appUrl, { method: "GET", credentials: "include" });
|
|
211
212
|
if (res.ok) {
|
|
212
213
|
const html = await res.text();
|
|
213
214
|
const match = html.match(/frappe\.csrf_token\s*=\s*"([^"]+)"/);
|
|
@@ -219,6 +220,19 @@ export class HttpClient {
|
|
|
219
220
|
return undefined;
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
private sendGated(url: string, init: RequestInit, sessionChange = false): Promise<Response> {
|
|
224
|
+
const method = (init.method ?? HttpMethod.Get).toUpperCase();
|
|
225
|
+
return sendThroughSessionGate(
|
|
226
|
+
splitBaseURL(url).origin,
|
|
227
|
+
{
|
|
228
|
+
sessionChange,
|
|
229
|
+
replayable: method === HttpMethod.Get || method === HttpMethod.Head,
|
|
230
|
+
signal: init.signal,
|
|
231
|
+
},
|
|
232
|
+
(signal) => fetch(url, { ...init, signal }),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
private invalidateNativeCsrfToken(): void {
|
|
223
237
|
this.csrfToken = undefined;
|
|
224
238
|
this.csrfBootstrapDone = false;
|
|
@@ -281,6 +295,7 @@ export class HttpClient {
|
|
|
281
295
|
headers?: Record<string, string>;
|
|
282
296
|
/** Cancels the request (and any pending retry) when aborted. */
|
|
283
297
|
signal?: AbortSignal;
|
|
298
|
+
sessionChange?: boolean;
|
|
284
299
|
},
|
|
285
300
|
): Promise<HttpResponse<T>> {
|
|
286
301
|
const url = this.resolveURL(path);
|
|
@@ -366,13 +381,15 @@ export class HttpClient {
|
|
|
366
381
|
if (csrf) headers.set("X-Frappe-CSRF-Token", csrf);
|
|
367
382
|
}
|
|
368
383
|
const makeRequest = (): Promise<Response> => {
|
|
369
|
-
|
|
384
|
+
const init: RequestInit = {
|
|
370
385
|
method,
|
|
371
386
|
headers,
|
|
372
387
|
credentials,
|
|
373
388
|
body,
|
|
374
389
|
signal: request?.signal,
|
|
375
|
-
}
|
|
390
|
+
};
|
|
391
|
+
if (!cookieAuth || !isBrowser()) return fetch(url, init);
|
|
392
|
+
return this.sendGated(url, init, request?.sessionChange);
|
|
376
393
|
};
|
|
377
394
|
let lastError: unknown;
|
|
378
395
|
let attempt = 0;
|
|
@@ -438,8 +455,13 @@ export class HttpClient {
|
|
|
438
455
|
return this.request<T>(HttpMethod.Get, `${path}${qs}`, { headers, signal });
|
|
439
456
|
}
|
|
440
457
|
|
|
441
|
-
post<T>(
|
|
442
|
-
|
|
458
|
+
post<T>(
|
|
459
|
+
path: string,
|
|
460
|
+
body?: RequestBody,
|
|
461
|
+
headers?: Record<string, string>,
|
|
462
|
+
options?: { sessionChange?: boolean },
|
|
463
|
+
) {
|
|
464
|
+
return this.request<T>(HttpMethod.Post, path, { headers, body, ...options });
|
|
443
465
|
}
|
|
444
466
|
|
|
445
467
|
put<T>(path: string, body?: RequestBody, headers?: Record<string, string>) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orders cookie-carrying requests around a session change (FZ-10892).
|
|
3
|
+
*
|
|
4
|
+
* Frappe sets `sid` on every response but the login's, so a guest response
|
|
5
|
+
* carries `sid=Guest`, and the cookie jar applies it whenever the response
|
|
6
|
+
* lands. A guest request still on the wire when a login completes would
|
|
7
|
+
* replace the session the login just set. The gate is per origin and shared
|
|
8
|
+
* by every HttpClient on it, since they share one jar: a session change
|
|
9
|
+
* waits for every request already sent to get its response, and a request
|
|
10
|
+
* issued meanwhile waits for the session change to get its own.
|
|
11
|
+
*
|
|
12
|
+
* The wait is capped at SESSION_CHANGE_WAIT_MS, since fetch has no timeout
|
|
13
|
+
* and a hung request must not block a login forever. At the cap the gate
|
|
14
|
+
* aborts the stragglers before it sends the session change. An aborted
|
|
15
|
+
* request's response is never processed, so its Set-Cookie can't land after
|
|
16
|
+
* the login. A replayable request (GET, HEAD) is sent again once the session
|
|
17
|
+
* change has its response; any other rejects with SessionChangedError.
|
|
18
|
+
*/
|
|
19
|
+
export const SESSION_CHANGE_WAIT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
export class SessionChangedError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("Request dropped: the session changed while it was in flight");
|
|
24
|
+
this.name = "AbortError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SentRequest {
|
|
29
|
+
controller: AbortController;
|
|
30
|
+
dropped: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface OriginGate {
|
|
34
|
+
inFlight: Set<SentRequest>;
|
|
35
|
+
idle: Array<() => void>;
|
|
36
|
+
change?: Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionGateOptions {
|
|
40
|
+
sessionChange?: boolean;
|
|
41
|
+
replayable?: boolean;
|
|
42
|
+
signal?: AbortSignal | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const gates = new Map<string, OriginGate>();
|
|
46
|
+
|
|
47
|
+
function gateFor(origin: string): OriginGate {
|
|
48
|
+
let gate = gates.get(origin);
|
|
49
|
+
if (!gate) {
|
|
50
|
+
gate = { inFlight: new Set(), idle: [] };
|
|
51
|
+
gates.set(origin, gate);
|
|
52
|
+
}
|
|
53
|
+
return gate;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function sendOnce<T>(
|
|
57
|
+
gate: OriginGate,
|
|
58
|
+
signal: AbortSignal | null | undefined,
|
|
59
|
+
send: (signal?: AbortSignal) => Promise<T>,
|
|
60
|
+
): Promise<
|
|
61
|
+
{ sent: SentRequest; ok: true; result: T } | { sent: SentRequest; ok: false; error: unknown }
|
|
62
|
+
> {
|
|
63
|
+
while (gate.change) await gate.change;
|
|
64
|
+
const sent: SentRequest = { controller: new AbortController(), dropped: false };
|
|
65
|
+
const forward = () => sent.controller.abort(signal?.reason);
|
|
66
|
+
if (signal?.aborted) forward();
|
|
67
|
+
else signal?.addEventListener("abort", forward, { once: true });
|
|
68
|
+
gate.inFlight.add(sent);
|
|
69
|
+
try {
|
|
70
|
+
return { sent, ok: true, result: await send(sent.controller.signal) };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { sent, ok: false, error };
|
|
73
|
+
} finally {
|
|
74
|
+
signal?.removeEventListener("abort", forward);
|
|
75
|
+
gate.inFlight.delete(sent);
|
|
76
|
+
if (gate.inFlight.size === 0) for (const wake of gate.idle.splice(0)) wake();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function sendRequest<T>(
|
|
81
|
+
gate: OriginGate,
|
|
82
|
+
options: SessionGateOptions,
|
|
83
|
+
send: (signal?: AbortSignal) => Promise<T>,
|
|
84
|
+
): Promise<T> {
|
|
85
|
+
for (;;) {
|
|
86
|
+
const outcome = await sendOnce(gate, options.signal, send);
|
|
87
|
+
if (!outcome.sent.dropped) {
|
|
88
|
+
if (!outcome.ok) throw outcome.error;
|
|
89
|
+
return outcome.result;
|
|
90
|
+
}
|
|
91
|
+
if (!options.replayable) throw new SessionChangedError();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function waitForIdle(gate: OriginGate): Promise<boolean> {
|
|
96
|
+
if (gate.inFlight.size === 0) return Promise.resolve(true);
|
|
97
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
98
|
+
const idle = new Promise<boolean>((resolve) => {
|
|
99
|
+
gate.idle.push(() => resolve(true));
|
|
100
|
+
});
|
|
101
|
+
const capped = new Promise<boolean>((resolve) => {
|
|
102
|
+
timer = setTimeout(() => resolve(false), SESSION_CHANGE_WAIT_MS);
|
|
103
|
+
});
|
|
104
|
+
return Promise.race([idle, capped]).finally(() => clearTimeout(timer));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function sendSessionChange<T>(
|
|
108
|
+
gate: OriginGate,
|
|
109
|
+
options: SessionGateOptions,
|
|
110
|
+
send: (signal?: AbortSignal) => Promise<T>,
|
|
111
|
+
): Promise<T> {
|
|
112
|
+
while (gate.change) await gate.change;
|
|
113
|
+
let release = () => {};
|
|
114
|
+
gate.change = new Promise<void>((resolve) => {
|
|
115
|
+
release = resolve;
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
if (!(await waitForIdle(gate))) {
|
|
119
|
+
for (const sent of gate.inFlight) {
|
|
120
|
+
sent.dropped = true;
|
|
121
|
+
sent.controller.abort(new SessionChangedError());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return await send(options.signal ?? undefined);
|
|
125
|
+
} finally {
|
|
126
|
+
gate.change = undefined;
|
|
127
|
+
release();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function sendThroughSessionGate<T>(
|
|
132
|
+
origin: string,
|
|
133
|
+
options: SessionGateOptions,
|
|
134
|
+
send: (signal?: AbortSignal) => Promise<T>,
|
|
135
|
+
): Promise<T> {
|
|
136
|
+
const gate = gateFor(origin);
|
|
137
|
+
return options.sessionChange
|
|
138
|
+
? sendSessionChange(gate, options, send)
|
|
139
|
+
: sendRequest(gate, options, send);
|
|
140
|
+
}
|
|
@@ -352,15 +352,34 @@ describe("RealtimeHandler", () => {
|
|
|
352
352
|
|
|
353
353
|
eventHandlers.connected();
|
|
354
354
|
|
|
355
|
-
expect(onRealtimeEvent).
|
|
356
|
-
type: RealtimeEventType.Connected,
|
|
357
|
-
});
|
|
355
|
+
expect(onRealtimeEvent).not.toHaveBeenCalled();
|
|
358
356
|
expect(onBackfillAll).toHaveBeenCalled();
|
|
359
357
|
expect(console.debug).toHaveBeenCalledWith(
|
|
360
358
|
"[RealtimeHandler] Connected to realtime server",
|
|
361
359
|
);
|
|
362
360
|
});
|
|
363
361
|
|
|
362
|
+
it("sends the connected signal on a reconnect only, so first-load fetches don't run twice (FZ-10892)", () => {
|
|
363
|
+
const onRealtimeEvent = vi.fn();
|
|
364
|
+
|
|
365
|
+
realtimeHandler = new RealtimeHandler(
|
|
366
|
+
mockDb,
|
|
367
|
+
mockRealtime,
|
|
368
|
+
mockNormalizedCache,
|
|
369
|
+
mockSubscriptionManager,
|
|
370
|
+
mockRehydrationManager,
|
|
371
|
+
{ onRealtimeEvent },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
eventHandlers.connected();
|
|
375
|
+
expect(onRealtimeEvent).not.toHaveBeenCalled();
|
|
376
|
+
|
|
377
|
+
eventHandlers.disconnect();
|
|
378
|
+
eventHandlers.connected();
|
|
379
|
+
expect(onRealtimeEvent).toHaveBeenCalledTimes(1);
|
|
380
|
+
expect(onRealtimeEvent).toHaveBeenCalledWith({ type: RealtimeEventType.Connected });
|
|
381
|
+
});
|
|
382
|
+
|
|
364
383
|
it("should reset reconnection attempts on successful connection", () => {
|
|
365
384
|
// Simulate previous failed connections
|
|
366
385
|
eventHandlers.disconnect();
|
|
@@ -778,7 +797,7 @@ describe("RealtimeHandler", () => {
|
|
|
778
797
|
eventHandlers.perm_epoch_bump();
|
|
779
798
|
eventHandlers.connected();
|
|
780
799
|
|
|
781
|
-
expect(callbacks.onRealtimeEvent).toHaveBeenCalledTimes(
|
|
800
|
+
expect(callbacks.onRealtimeEvent).toHaveBeenCalledTimes(3);
|
|
782
801
|
expect(callbacks.onResetCache).toHaveBeenCalledTimes(1);
|
|
783
802
|
expect(callbacks.onBackfillAll).toHaveBeenCalledTimes(1);
|
|
784
803
|
});
|
package/src/sync/realtime.ts
CHANGED
|
@@ -144,6 +144,7 @@ export class RealtimeHandler {
|
|
|
144
144
|
* Handles successful connection.
|
|
145
145
|
*/
|
|
146
146
|
private onConnected() {
|
|
147
|
+
const reconnect = this.hasConnected;
|
|
147
148
|
this.hasConnected = true;
|
|
148
149
|
this.connected = true;
|
|
149
150
|
this.reconnectAttempts = 0;
|
|
@@ -154,7 +155,7 @@ export class RealtimeHandler {
|
|
|
154
155
|
this.reconnectTimer = undefined;
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
if (this.options.onRealtimeEvent) {
|
|
158
|
+
if (reconnect && this.options.onRealtimeEvent) {
|
|
158
159
|
this.options.onRealtimeEvent({ type: RealtimeEventType.Connected });
|
|
159
160
|
}
|
|
160
161
|
|
package/types/auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;gBAEtB,IAAI,EAAE,UAAU;IAItB,yBAAyB,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;gBAEtB,IAAI,EAAE,UAAU;IAItB,yBAAyB,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE;IASvE,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC;IAOlC,MAAM;IAIN,cAAc,CAAC,KAAK,EAAE,MAAM;CAKnC"}
|
package/types/core/http.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export declare class HttpClient {
|
|
|
79
79
|
*/
|
|
80
80
|
private ensureNativeCsrfToken;
|
|
81
81
|
private fetchNativeCsrfToken;
|
|
82
|
+
private sendGated;
|
|
82
83
|
private invalidateNativeCsrfToken;
|
|
83
84
|
/**
|
|
84
85
|
* An absolute path ("/api/method/x") resolves under the base URL's own path,
|
|
@@ -95,9 +96,12 @@ export declare class HttpClient {
|
|
|
95
96
|
headers?: Record<string, string>;
|
|
96
97
|
/** Cancels the request (and any pending retry) when aborted. */
|
|
97
98
|
signal?: AbortSignal;
|
|
99
|
+
sessionChange?: boolean;
|
|
98
100
|
}): Promise<HttpResponse<T>>;
|
|
99
101
|
get<T>(path: string, params?: Record<string, unknown>, headers?: Record<string, string>, signal?: AbortSignal): Promise<HttpResponse<T>>;
|
|
100
|
-
post<T>(path: string, body?: RequestBody, headers?: Record<string, string
|
|
102
|
+
post<T>(path: string, body?: RequestBody, headers?: Record<string, string>, options?: {
|
|
103
|
+
sessionChange?: boolean;
|
|
104
|
+
}): Promise<HttpResponse<T>>;
|
|
101
105
|
put<T>(path: string, body?: RequestBody, headers?: Record<string, string>): Promise<HttpResponse<T>>;
|
|
102
106
|
delete<T>(path: string, body?: RequestBody, headers?: Record<string, string>): Promise<HttpResponse<T>>;
|
|
103
107
|
head(path: string, params?: Record<string, unknown>, headers?: Record<string, string>): Promise<HttpResponse<void>>;
|