skip to Main Content

When importing a JS module (e.g. https://example.com/module.js) from a URL that returns a 301/302 redirect to a specific version of the module (e.g. https://example.com/module-42.js), the redirected module is initiated as a separate instance.
This means that when explicitly importing the module from both the initial URL and the versioned URL, two instances of the module are created:

import "https://example.com/module.js"; // -> redirects to https://example.com/module-42.js
import "https://example.com/module-42.js";

Nevertheless, the value of import.meta.url inside the module is "https://example.com/module-42.js" in both cases:

// https://example.com/module-42.js
console.log(import.meta.url)

This behavior is the same in all browsers (Chrome, Firefox, Safarie), but in Deno, the redirected URL and the versioned URL both point to the same module instance and only initialize the module once.

Imo, the way Deno handles redirects makes more sense because the redirect does not just point to the same module, it points to the identical resource which should not be treated as a separate module instance.

Reproducible example:

(async () =>
  console.log(
    (await import("https://redirect-example.unyt.app/module")).default == 
    (await import("https://redirect-example.unyt.app/module@version")).default
  )
)()

This evaluates to false when executed in a browser environment, but evaluates to true when executed in Deno.

This is a big problem when the modules have side effects during module initialization, since all side effects are executed twice.

Another scenario for which this is a big issue is when a redirected module (https://example.com/moduleA.js -> https://example.com/version1/moduleA.js) has a relative import to ./moduleB.js, which resolves correctly to https://example.com/version1/moduleB.js.

But if we are not importing https://example.com/moduleA.js and https://example.com/moduleB.js (which gets resolved to https://example.com/version1/moduleB.js) from our root module, we get one instantiation of the redirected https://example.com/version1/moduleA.js, but two instantiations of moduleB: One instantiation through the root module import, and a different instantiation through the relative import from moduleA – the two modules are not treated as the same module!

Is this intended behavior (is there a specification for this somewhere since this is not part of the ECMAScript spec) and is there any way to get around this problem?

2

Answers


  1. Browser loads two modules, but their contents are identical (point to same space in memory). Usually you use what is exported from a module, not some itermediate object module is kept in. If we take your example and modify it to compare what’s inside the modules, you will get different result:

    (async () =>
      console.log(
        await import("https://esm.sh/canvas-confetti").then(module => module.default) == 
        await import("https://esm.sh/[email protected]").then(module => module.default)
      )
    )()

    And it’s pretty fine, since usually you want content of the module anyway.

    So, you can pretty much do something like that:

    import confettiPlain, {create as createPlain} from "https://esm.sh/canvas-confetti";
    import confettiVersioned, {create as createVersioned} from "https://esm.sh/[email protected]";
    
    console.log(confettiPlain === confettiVersioned); // true
    console.log(createPlain === createVersioned); // true
    
    confettiPlain.myProp = "1234";
    console.log(confettiVersioned.myProp); // gives "1234"
    
    Login or Signup to reply.
  2. Context

    How are ES6 module imports with HTTP redirects supposed to work?

    Before getting to an answer… I interpret the question to be asking how:

    • import specifiers which are also valid URLs
    • that cause network requests to be made which result in responses that are categorized as URL redirection
    • will resolve to specific module data, and whether multiple redirections to the same resource will result in multiple evaluations of the same module code

    Answer

    Before anything else: you were correct when you said:

    …this is not part of the ECMAScript spec…

    Module specifiers are indeed simply opaque strings — a given runtime can resolve specifiers however it is programmed to do so.

    Some specifiers might resemble URLs, and a runtime might or might not resolve the specifier by making a network request. Or the runtime might implement its own internal specifier mappings… or it might implement a caching layer… or any other number of intermediate steps between parsing the specifier string and resolving module data. The spec doesn’t prescribe how this should happen, which allows for flexibility.

    There are no committed alignments across runtimes in regard to the way that URL-like specifiers should be resolved to module data (or whether those modules should be evaluated more than once), and — as you observed — they aren’t implemented in the same way. The network layer itself in each runtime can also be implemented in an arbitrary way.

    For these reasons, there are no guarantees of conformity beyond what each runtime provides for itself. You’ll have to discover and verify claims for yourself.


    Toward helping you test various runtimes

    Below, I will provide some code that you can use to test various runtimes/browsers. It was created and tested using Deno v1.45.3, but you should be able to adapt the code in the server module to other JS runtimes — I used only ECMA and WHATWG APIs (nothing Deno-specific):

    .env:

    HOSTNAME=localhost
    PORT=8000
    
    

    deno.json:

    {
      "compilerOptions": {
        "exactOptionalPropertyTypes": true,
        "noImplicitOverride": true,
        "noImplicitReturns": true,
        "noUncheckedIndexedAccess": true,
        "useUnknownInCatchVariables": true,
        "lib": ["deno.window"]
      },
      "imports": {
        "@std/assert": "jsr:@std/assert@^1.0.0"
      },
      "tasks": {
        "serve": "deno run --no-prompt --env --allow-env=HOSTNAME,PORT --allow-net=localhost:8000 serve.ts",
        "test": "deno test --no-prompt --env --allow-env=HOSTNAME,PORT --allow-net=localhost:8000 server.test.ts"
      }
    }
    
    

    env.ts:

    import { assert } from "@std/assert/assert";
    
    export function getPartialEnv<const T extends string>(
      keys: Iterable<T>,
    ): Record<T, string> {
      const env = {} as Record<T, string>;
      for (const k of keys) {
        const v = Deno.env.get(k);
        assert(v, `Environment variable not found for "${k}"`);
        env[k] = v;
      }
      return env;
    }
    
    export default getPartialEnv(["HOSTNAME", "PORT"]);
    
    

    serve.ts:

    import env from "./env.ts";
    
    import { handleRequest } from "./server.ts";
    
    await using server = Deno.serve({
      handler: handleRequest,
      hostname: env.HOSTNAME,
      port: Number(env.PORT),
    });
    
    await server.finished;
    
    

    server.test.ts:

    import { assertEquals } from "@std/assert/equals";
    import { assertStrictEquals } from "@std/assert/strict-equals";
    
    import env from "./env.ts";
    
    import { handleRequest, pathPrefix } from "./server.ts";
    
    Deno.test("server", async (ctx) => {
      const {
        promise: serverStarted,
        resolve: resolveServerStarted,
      } = Promise.withResolvers<void>();
    
      await using server = Deno.serve({
        handler: handleRequest,
        hostname: env.HOSTNAME,
        onListen: () => resolveServerStarted(),
        port: Number(env.PORT),
      });
    
      await ctx.step("starts", () => serverStarted);
    
      await ctx.step("responds with side-effect modules", async () => {
        const specifier =
          new URL(`http://${env.HOSTNAME}:${env.PORT}${pathPrefix}/b.js`).href;
        await import(specifier);
      });
    
      await ctx.step("expected modules are evaluated only once", async (ctx) => {
        const countsActual = (globalThis as typeof globalThis & {
          _counts: Partial<Record<string, number>>;
        })._counts;
    
        const countsExpected: Partial<Record<string, number>> = {};
    
        const fileNames = ["a.js", "b.js"];
    
        for (const fileName of fileNames) {
          const key =
            new URL(`http://${env.HOSTNAME}:${env.PORT}${pathPrefix}/${fileName}`)
              .href;
          countsExpected[key] = 1;
          await ctx.step(
            `${JSON.stringify(fileName)}`,
            () => assertStrictEquals(countsActual[key], 1),
          );
        }
    
        await ctx.step(
          "no additional modules evaluated",
          () => assertEquals(countsActual, countsExpected),
        );
        // console.log(countsActual);
      });
    
      await ctx.step("stops", async () => {
        await Promise.all([server.shutdown(), server.finished]);
      });
    });
    
    

    server.ts:

    // URL path prefix for module cache isolation
    export const pathPrefix = "/so-78785224";
    
    // increment a global count each time a module is evaluated
    const incrementEvalCount = `const counts = (globalThis._counts ??= {});
    counts[import.meta.url] = (counts[import.meta.url] ?? 0) + 1;`;
    
    const indexHtml = `<!doctype html>
    <html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Module evalutaion test - Stack Overflow Question ID 78785224</title>
        <script type="module">
            import "${pathPrefix}/b.js";
            console.log("module evaluation counts:", globalThis._counts);
        </script>
    </head>
    <body>See dev tools</body>
    </html>`;
    
    function createTextResponse(text: string, contentType: string): Response {
      const bytes = new TextEncoder().encode(text);
      return new Response(bytes, {
        headers: new Headers([
          ["Content-Length", bytes.byteLength.toString()],
          ["Content-Type", contentType],
        ]),
      });
    }
    
    export function handleRequest(request: Request): Response {
      const url = new URL(request.url);
    
      if (url.pathname === "/") return createTextResponse(indexHtml, "text/html");
    
      if (url.pathname.startsWith(pathPrefix)) {
        switch (url.pathname.slice(pathPrefix.length)) {
          case "/a.js": {
            const moduleText = `${incrementEvalCount}nexport default "a"n`;
            return createTextResponse(moduleText, "text/javascript");
          }
          case "/a2.js": {
            // redirect
            return new Response(null, {
              headers: new Headers([
                ["Location", new URL("./a.js", url.href).href],
              ]),
              status: 301,
            });
          }
          case "/b.js": {
            const moduleText =
              `import "./a.js";nimport "./a2.js";n${incrementEvalCount}nexport default "b"n`;
            return createTextResponse(moduleText, "text/javascript");
          }
        }
      }
      return new Response("Not found", { status: 404 });
    }
    
    export default { fetch: handleRequest };
    
    

    You can run the server with the following command in order to test various runtimes:

    deno task serve
    

    …or test with Deno directly using

    deno task test
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search