core icon indicating copy to clipboard operation
core copied to clipboard

When a remote is offline, it takes multiple seconds on Windows before a remote is considered as unavailable.

Open patricklafrance opened this issue 1 year ago • 7 comments

Describe the bug

When a remote is offline, it takes multiple seconds on Windows before a remote is considered as unavailable, leaving the users with a blank page until the shared dependencies negotiation is done and the application is rendered.

According to my POC, a production build hosted on Netlify, when a remote is offline on Windows, it takes approximately 2500ms until the application is rendered.

On macOS, it takes about 20ms, which is a lot faster.

This delay doesn't seems to be related to any custom code but rather on how Windows behave when a connection is refused. It seems to retry 3 times before failing with ERR_CONNECTION_REFUSED.

image

Similar issues has been observed for other projects:

  • https://stackoverflow.com/questions/19440364/why-do-failed-attempts-of-socket-connect-take-1-sec-on-windows
  • https://github.com/warmcat/libwebsockets/issues/570

@ryok90 and I have been extensively discussing about this issue in the past few days on the Module Federation Discord's server and came to the conclusion that there is currently nothing offered by Module Federation to actually help with this issue: https://discord.com/channels/1055442562959290389/1060923312043212920/threads/1232010715381108929

👉🏻 We believe that the solution would be for Module Federation to include a mechanism allowing the authors to specify a timeout delay to fetch a remote, rather the relying on the OS defaults.

When the host is configuring a remote with a mf-manifest.json file, it seems like the manifest is fetched with fetch, a timeout could be introduced with an AbortSignal.

When the host is configuring a remote with a remoteEntry.js file, it seems like the remote is fetched with a script element. Something similar to the following code could be added to eagerly reject a remote when it is offline. This is how we used to do it with Module Federation 1.0:

function loadRemoteScript(url: string, { timeoutDelay = 500 }: LoadRemoteScriptOptions = {}) {
    return new Promise((resolve, reject) => {
        const element = document.createElement("script");

        // Adding a timestamp to make sure the remote entry points are never cached.
        // View: https://github.com/module-federation/module-federation-examples/issues/566.
        element.src = `${url}?t=${Date.now()}`;
        element.type = "text/javascript";
        element.async = true;

        let timeoutId: number | undefined = undefined;
        let hasCanceled = false;

        function cancel(error: Error) {
            hasCanceled = true;

            element?.parentElement?.removeChild(element);

            reject({
                error,
                hasCanceled: true
            });
        }

        element.onload = () => {
            window.clearTimeout(timeoutId);

            element?.parentElement?.removeChild(element);
            resolve({});
        };

        element.onerror = (error: unknown) => {
            if (!hasCanceled) {
                window.clearTimeout(timeoutId);

                element?.parentElement?.removeChild(element);

                reject({
                    error,
                    hasCanceled: false
                });
            }
        };

        document.head.appendChild(element);

        // Eagerly reject the loading of a script, it's too long when a remote is unavailable.
        timeoutId = window.setTimeout(() => {
            cancel(new Error(`[squide] Remote script "${url}" time-outed.`));
        }, timeoutDelay);
    });
}

Thank you,

Patrick

Reproduction

https://pat-mf-enhanced-poc.netlify.app/

Used Package Manager

pnpm

System Info

System:
    OS: Windows 11 10.0.22631
    CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
    Memory: 13.30 GB / 31.70 GB
  Binaries:
    Node: 21.7.1 - C:\Program Files\nodejs\node.EXE
    Yarn: 1.22.19 - C:\Program Files (x86)\Yarn\bin\yarn.CMD
    npm: 10.5.0 - C:\Program Files\nodejs\npm.CMD
    pnpm: 8.15.4 - ~\AppData\Roaming\npm\pnpm.CMD
  Browsers:
    Chrome: 124.0.6367.61
    Edge: Chromium (123.0.2420.97)

Validations

patricklafrance avatar Apr 23 '24 16:04 patricklafrance

createScript hook is probbably what you are after: https://module-federation.io/plugin/dev/index.html#createscript

ScriptedAlchemy avatar Apr 23 '24 23:04 ScriptedAlchemy

@ScriptedAlchemy I have been on this road, the issue with the createScript hook is that I don't have any control over when Module Federation choose to stop waiting for the remote to be available, it's basically only a script element factory.

patricklafrance avatar Apr 23 '24 23:04 patricklafrance

cant you throw the script after timeout?

ScriptedAlchemy avatar Apr 24 '24 01:04 ScriptedAlchemy

@ScriptedAlchemy not exactly.

If I always throw an Error in the createScript hook:

createScript({ url }) {
    throw new Error(`Remote script "${url}" time-outed.`);
}

Then, it kinds of work as expected since the catch handler of my loadRemote function catch the error:

await loadRemote("remote1/HelloWorld.jsx")
    .then(mod => {
        console.log("Loaded remote 1", mod);
    })
    .catch((error) => console.log("Failed to load remote 1", error));

But the error that is catched, is not the exception I thrown:

TypeError: remoteEntryExports.get is not a function

Then, if I delay the throw of the Error by 500ms for the timeout:

createScript({ url }) {
    const element = document.createElement("script");

    // Adding a timestamp to make sure the remote entry points are never cached.
    // View: https://github.com/module-federation/module-federation-examples/issues/566.
    element.src = `${url}?t=${Date.now()}`;
    element.type = "text/javascript";
    element.async = true;

    let timeoutId = undefined;

    element.onload = () => {
        window.clearTimeout(timeoutId);
    }

    // Eagerly reject the loading of a script, it's too long when a remote is unavailable.
    timeoutId = window.setTimeout(() => {
        throw new Error(`Remote script "${url}" time-outed.`);
    }, 500);

    return element;
}

It doesn't work anymore as the error is not catched anymore by the catch handler of the loadRemote function and it crashes the application. I don't feel like the code around the createScript hook has been written to handle a throw.

patricklafrance avatar Apr 24 '24 14:04 patricklafrance

I also tried hustling something with the beforeRequest hook, but I haven't been to because of https://github.com/module-federation/universe/issues/2371.

patricklafrance avatar Apr 24 '24 18:04 patricklafrance

@patricklafrance https://github.com/module-federation/core/pull/2433/files

hows that?

This would change the return types of createScript

return htmlElement | {script?: htmlElement, timeout?:number}

return {script: myScript} return {timeout: 1000} return {script,timeout} return scriptElement

ScriptedAlchemy avatar May 01 '24 04:05 ScriptedAlchemy

@ScriptedAlchemy LGTM. One question thought, as a consumer, how do I handle:

onScriptComplete(null, new Error(`Remote script "${url}" time-outed.`)); ?

Is it with either the errorLoadRemote hook or loadRemote.catch ?

patricklafrance avatar May 01 '24 13:05 patricklafrance

Stale issue message

github-actions[bot] avatar Jul 01 '24 15:07 github-actions[bot]