@multiplatform.one/frappe 9.1.0 → 9.2.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.
Files changed (59) hide show
  1. package/dist/cjs/auth.cjs +5 -1
  2. package/dist/cjs/auth.native.js +5 -1
  3. package/dist/cjs/auth.native.js.map +1 -1
  4. package/dist/cjs/core/http.cjs +21 -6
  5. package/dist/cjs/core/http.native.js +27 -6
  6. package/dist/cjs/core/http.native.js.map +1 -1
  7. package/dist/cjs/core/sessionGate.cjs +121 -0
  8. package/dist/cjs/core/sessionGate.native.js +284 -0
  9. package/dist/cjs/core/sessionGate.native.js.map +1 -0
  10. package/dist/cjs/sync/realtime.cjs +2 -1
  11. package/dist/cjs/sync/realtime.native.js +2 -1
  12. package/dist/cjs/sync/realtime.native.js.map +1 -1
  13. package/dist/esm/auth.mjs +5 -1
  14. package/dist/esm/auth.mjs.map +1 -1
  15. package/dist/esm/auth.native.js +5 -1
  16. package/dist/esm/auth.native.js.map +1 -1
  17. package/dist/esm/core/http.mjs +21 -6
  18. package/dist/esm/core/http.mjs.map +1 -1
  19. package/dist/esm/core/http.native.js +27 -6
  20. package/dist/esm/core/http.native.js.map +1 -1
  21. package/dist/esm/core/sessionGate.mjs +94 -0
  22. package/dist/esm/core/sessionGate.mjs.map +1 -0
  23. package/dist/esm/core/sessionGate.native.js +254 -0
  24. package/dist/esm/core/sessionGate.native.js.map +1 -0
  25. package/dist/esm/sync/realtime.mjs +2 -1
  26. package/dist/esm/sync/realtime.mjs.map +1 -1
  27. package/dist/esm/sync/realtime.native.js +2 -1
  28. package/dist/esm/sync/realtime.native.js.map +1 -1
  29. package/dist/jsx/auth.mjs +5 -1
  30. package/dist/jsx/auth.mjs.map +1 -1
  31. package/dist/jsx/auth.native.js +5 -1
  32. package/dist/jsx/auth.native.js.map +1 -1
  33. package/dist/jsx/core/http.mjs +21 -6
  34. package/dist/jsx/core/http.mjs.map +1 -1
  35. package/dist/jsx/core/http.native.js +27 -6
  36. package/dist/jsx/core/http.native.js.map +1 -1
  37. package/dist/jsx/core/sessionGate.mjs +94 -0
  38. package/dist/jsx/core/sessionGate.mjs.map +1 -0
  39. package/dist/jsx/core/sessionGate.native.js +284 -0
  40. package/dist/jsx/core/sessionGate.native.js.map +1 -0
  41. package/dist/jsx/sync/realtime.mjs +2 -1
  42. package/dist/jsx/sync/realtime.mjs.map +1 -1
  43. package/dist/jsx/sync/realtime.native.js +2 -1
  44. package/dist/jsx/sync/realtime.native.js.map +1 -1
  45. package/package.json +7 -7
  46. package/src/auth.sessionRace.spec.ts +209 -0
  47. package/src/auth.spec.ts +33 -14
  48. package/src/auth.ts +7 -5
  49. package/src/core/http.spec.ts +16 -10
  50. package/src/core/http.ts +28 -6
  51. package/src/core/sessionGate.ts +140 -0
  52. package/src/sync/realtime.spec.ts +23 -4
  53. package/src/sync/realtime.ts +2 -1
  54. package/types/auth.d.ts.map +1 -1
  55. package/types/core/http.d.ts +5 -1
  56. package/types/core/http.d.ts.map +1 -1
  57. package/types/core/sessionGate.d.ts +29 -0
  58. package/types/core/sessionGate.d.ts.map +1 -0
  59. 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("/api/method/login", {
85
- usr: "testuser",
86
- pwd: "testpass123",
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("/api/method/login", {
106
- usr: "test@example.com",
107
- pwd: "securePassword!",
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("/api/method/login", {
126
- usr: loginData.username,
127
- pwd: loginData.password,
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("/api/method/login", {
12
- usr: input.username,
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) {
@@ -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
- `${baseURL}/test`,
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
- `${baseURL}/test?a=1`,
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 fetch(rpcUrl, { method: "GET", credentials: "include" });
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 fetch(appUrl, { method: "GET", credentials: "include" });
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
- return fetch(url, {
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>(path: string, body?: RequestBody, headers?: Record<string, string>) {
442
- return this.request<T>(HttpMethod.Post, path, { headers, body });
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).toHaveBeenCalledWith({
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(4);
800
+ expect(callbacks.onRealtimeEvent).toHaveBeenCalledTimes(3);
782
801
  expect(callbacks.onResetCache).toHaveBeenCalledTimes(1);
783
802
  expect(callbacks.onBackfillAll).toHaveBeenCalledTimes(1);
784
803
  });
@@ -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
 
@@ -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;IAOvE,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC;IAOlC,MAAM;IAIN,cAAc,CAAC,KAAK,EAAE,MAAM;CAKnC"}
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"}
@@ -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>): Promise<HttpResponse<T>>;
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>>;