fix(frontend): binder le fetch global à globalThis (pairing Firefox) (#13)
Firefox exige que fetch soit appelé sur Window : passer la référence nue `fetch` en dépendance déclenchait « 'fetch' called on an object that does not implement interface Window » au pairing. Nouveau helper defaultFetch() qui binde globalThis.fetch, utilisé par webSession. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -7,9 +7,37 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
import type { FetchLike } from "./httpInvoker";
|
import type { FetchLike } from "./httpInvoker";
|
||||||
import { HttpInvoker } from "./httpInvoker";
|
import { HttpInvoker, defaultFetch } from "./httpInvoker";
|
||||||
import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways";
|
import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs a global `fetch` that records its receiver (`this`), runs `body`, then
|
||||||
|
* restores the original. A real browser's `fetch` rejects a non-global receiver
|
||||||
|
* (Firefox: "'fetch' called on an object that does not implement interface
|
||||||
|
* Window"); jsdom does not enforce it, so the receiver is asserted explicitly.
|
||||||
|
*/
|
||||||
|
async function withRecordingGlobalFetch(
|
||||||
|
body: () => Promise<void>,
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
const receivers: unknown[] = [];
|
||||||
|
globalThis.fetch = function (this: unknown) {
|
||||||
|
receivers.push(this);
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
text: async () => "{}",
|
||||||
|
});
|
||||||
|
} as unknown as typeof globalThis.fetch;
|
||||||
|
try {
|
||||||
|
await body();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original;
|
||||||
|
}
|
||||||
|
return receivers;
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds a fake fetch that records the request and returns `body`. */
|
/** Builds a fake fetch that records the request and returns `body`. */
|
||||||
function fakeFetch(
|
function fakeFetch(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
@ -110,6 +138,52 @@ describe("HttpInvoker", () => {
|
|||||||
code: "TRANSPORT_ERROR",
|
code: "TRANSPORT_ERROR",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: the default global `fetch` was stored unbound, so calling it as
|
||||||
|
// `this.fetchImpl(…)` handed it the invoker as receiver — a TypeError in
|
||||||
|
// Firefox/Chrome at pairing. It must stay bound to `globalThis`.
|
||||||
|
it("calls the default global fetch with globalThis as receiver", async () => {
|
||||||
|
const receivers = await withRecordingGlobalFetch(async () => {
|
||||||
|
const http = new HttpInvoker({ baseUrl: "https://host" });
|
||||||
|
await http.invoke("health");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivers).toHaveLength(1);
|
||||||
|
expect(receivers[0]).toBe(globalThis);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an injected fetchImpl untouched (no receiver rebinding)", async () => {
|
||||||
|
const { fetchImpl, calls } = fakeFetch({ ok: true });
|
||||||
|
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl });
|
||||||
|
|
||||||
|
await http.invoke("health");
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultFetch", () => {
|
||||||
|
it("returns the global fetch bound to globalThis", async () => {
|
||||||
|
const receivers = await withRecordingGlobalFetch(async () => {
|
||||||
|
const fn = defaultFetch();
|
||||||
|
// Called as a bare reference (worst case: no receiver at all).
|
||||||
|
await fn("https://host/x");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivers).toEqual([globalThis]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through a missing global fetch instead of throwing at construction", () => {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
// @ts-expect-error — simulating an environment without a global fetch.
|
||||||
|
delete globalThis.fetch;
|
||||||
|
try {
|
||||||
|
// Must not throw here; the failure surfaces at call time, as before.
|
||||||
|
expect(defaultFetch()).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("request/response gateways preserve the Tauri command contract", () => {
|
describe("request/response gateways preserve the Tauri command contract", () => {
|
||||||
|
|||||||
@ -41,6 +41,29 @@ export type FetchLike = (
|
|||||||
text(): Promise<string>;
|
text(): Promise<string>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the default `fetch`, **bound to `globalThis`**.
|
||||||
|
*
|
||||||
|
* WHATWG `fetch` is a method of the global object and checks its receiver: once
|
||||||
|
* stored in a field and called as `this.fetchImpl(…)`, its `this` is the owning
|
||||||
|
* adapter instance, not `window`. A real browser rejects that — Firefox with
|
||||||
|
* `TypeError: 'fetch' called on an object that does not implement interface
|
||||||
|
* Window`, Chrome with "Illegal invocation". jsdom does not enforce the receiver,
|
||||||
|
* so the unit tests never caught it; the bind is what keeps the fallback callable
|
||||||
|
* from a field. An *injected* `fetchImpl` is left untouched (a test stub is a
|
||||||
|
* plain function and needs no receiver).
|
||||||
|
*
|
||||||
|
* When there is no global `fetch` (SSR / bare Node), the value is returned as-is
|
||||||
|
* so the failure still happens at call time, exactly as before, rather than
|
||||||
|
* throwing during construction.
|
||||||
|
*/
|
||||||
|
export function defaultFetch(): FetchLike {
|
||||||
|
const globalFetch = globalThis.fetch;
|
||||||
|
return (
|
||||||
|
typeof globalFetch === "function" ? globalFetch.bind(globalThis) : globalFetch
|
||||||
|
) as unknown as FetchLike;
|
||||||
|
}
|
||||||
|
|
||||||
/** Configuration for the HTTP invoker. */
|
/** Configuration for the HTTP invoker. */
|
||||||
export interface HttpInvokerConfig {
|
export interface HttpInvokerConfig {
|
||||||
/** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */
|
/** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */
|
||||||
@ -85,10 +108,8 @@ export class HttpInvoker {
|
|||||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
this.token = config.token;
|
this.token = config.token;
|
||||||
this.onUnauthorized = config.onUnauthorized;
|
this.onUnauthorized = config.onUnauthorized;
|
||||||
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
|
// The global fallback must stay bound to `globalThis` — see `defaultFetch`.
|
||||||
// to the minimal shape used here.
|
this.fetchImpl = config.fetchImpl ?? defaultFetch();
|
||||||
this.fetchImpl =
|
|
||||||
config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -91,6 +91,44 @@ describe("WebSession unauthorized handling", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WebSession default fetch receiver", () => {
|
||||||
|
/**
|
||||||
|
* Regression (live Firefox, ticket #13): the default global `fetch` was stored
|
||||||
|
* unbound, so `this.fetchImpl(…)` called it with the WebSession as receiver ⇒
|
||||||
|
* `TypeError: 'fetch' called on an object that does not implement interface
|
||||||
|
* Window` at pairing. jsdom does not enforce the receiver, so assert it here.
|
||||||
|
*/
|
||||||
|
async function pairWithRecordingGlobalFetch(): Promise<unknown[]> {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
const receivers: unknown[] = [];
|
||||||
|
globalThis.fetch = function (this: unknown) {
|
||||||
|
receivers.push(this);
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ ok: true }),
|
||||||
|
text: async () => "{}",
|
||||||
|
});
|
||||||
|
} as unknown as typeof globalThis.fetch;
|
||||||
|
try {
|
||||||
|
// No `fetchImpl` injected ⇒ the global fallback is used.
|
||||||
|
const session = new WebSession({ baseUrl: "https://host", store: memStore() });
|
||||||
|
await session.pair("4821-93");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = original;
|
||||||
|
}
|
||||||
|
return receivers;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("pairs using the global fetch bound to globalThis, not the session", async () => {
|
||||||
|
const receivers = await pairWithRecordingGlobalFetch();
|
||||||
|
|
||||||
|
expect(receivers).toHaveLength(1);
|
||||||
|
expect(receivers[0]).toBe(globalThis);
|
||||||
|
expect(receivers[0]).not.toBeInstanceOf(WebSession);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("WebSession logout", () => {
|
describe("WebSession logout", () => {
|
||||||
it("POSTs /api/logout same-origin and clears the flag on success", async () => {
|
it("POSTs /api/logout same-origin and clears the flag on success", async () => {
|
||||||
const store = memStore();
|
const store = memStore();
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GatewayError, Unsubscribe } from "@/domain";
|
import type { GatewayError, Unsubscribe } from "@/domain";
|
||||||
import type { FetchLike } from "./httpInvoker";
|
import { defaultFetch, type FetchLike } from "./httpInvoker";
|
||||||
|
|
||||||
const PAIRED_FLAG_KEY = "idea.web.paired";
|
const PAIRED_FLAG_KEY = "idea.web.paired";
|
||||||
|
|
||||||
@ -77,7 +77,8 @@ export class WebSession {
|
|||||||
|
|
||||||
constructor(config: WebSessionConfig = {}) {
|
constructor(config: WebSessionConfig = {}) {
|
||||||
this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, "");
|
this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, "");
|
||||||
this.fetchImpl = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
// The global fallback must stay bound to `globalThis` — see `defaultFetch`.
|
||||||
|
this.fetchImpl = config.fetchImpl ?? defaultFetch();
|
||||||
this.store = config.store ?? defaultStore();
|
this.store = config.store ?? defaultStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user