Skip to content

Commit

Permalink
Synchronous installation of import map, when possible. Resolves #2 (#5)
Browse files Browse the repository at this point in the history
* Synchronous installation of import map, when possible. Resolves #2

* Self review
  • Loading branch information
joeldenning authored Aug 24, 2023
1 parent d5afcd8 commit c0757b2
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 68 deletions.
75 changes: 69 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,20 @@ The import-map-injector.js file must execute before any ES module is loaded by t

### Via `<script>`

It's easiest to get best performance with import-map-injector by directly loading the import-map-injector.js file
It's easiest to get best performance with import-map-injector by directly loading the import-map-injector.js file into your HTML page via `<script type="text/javascript" src="./import-map-injector.js">`. It is important to place the `<script>` element after any `<script type="injector-importmap">` elements, but before any `<script type="module">` or `<script>import()</script>` elements.

```html
<!-- If you wish to auto-upgrade to latest import-map-injector versions, use the following URLs -->
<script src="https://cdn.jsdelivr.net/npm/import-map-injector"></script>
<script src="https://unpkg.com/import-map-injector"></script>

<!-- If you wish to pin to a specific version, swap VERSION with the version you're using -->
<script src="https://cdn.jsdelivr.net/npm/import-map-injector@VERSION"></script>
<script src="https://unpkg.com/import-map-injector@VERSION"></script>

<!-- If you wish to self host, that's possible too -->
<script src="./node_modules/import-map-injector/lib/import-map-injector.js"></script>
```

### Via npm

Expand All @@ -39,7 +52,7 @@ It's better for performance to put the import statement at the top of your bundl

## Usage

import-map-injector combines multiple `<script type="injector-importmap"></script>` elements into a final `<script type="importmap"></script>` element, which is injected into the `<head>` element of the web page. The browser spec for import maps requires the `<script type="importmap"></script>` to be injected before any ES modules are loaded via `<script type="module">`, and import-map-injector currently cannot complete import map installation synchronously (See #1).
import-map-injector combines multiple `<script type="injector-importmap"></script>` elements into a final `<script type="importmap"></script>` element, which is injected into the `<head>` element of the web page. The browser spec for import maps requires the `<script type="importmap"></script>` to be injected before any ES modules are loaded via `<script type="module">` or `import()`.

In your HTML page, add the following:

Expand Down Expand Up @@ -103,14 +116,64 @@ Once import map installation has finished, you can proceed with loading modules

### Detecting when import map installation is complete

As per the [Import Maps spec](https://github.com/WICG/import-maps), no ES modules can be loaded until import map installation is completed. This means that you cannot use `<script type="module">` or `import()` until after import map installation completes.

Note that the import map spec currently does not provide a way to use import map specifiers inside of `<script type="module">` elements. However, it is still possible to load modules in your import map via `<script type="module">` if you use a URL path rather than the import map specifier.

#### Synchronous import map installation

If you're not using external import maps (`<script type="injector-importmap" src="./external-url.importmap">`), import map installation occurs synchronously once the import-map-injector.js file is executed. This means you can use `<script type="module">` and `import()` in your HTML file, so long as they are after the `import-map-injector.js` file is executed.

**Example:**

```html
<script type="injector-importmap">
{
"imports": {
"my-module": "./hello-world.js"
}
}
</script>
<script type="injector-importmap">
{
"imports": {
"my-module2": "./hello-world2.js"
}
}
</script>
<script src="./import-map-injector.js"></script>
<!-- Since there are no external import maps, the import map is installed synchronously and we can immediately load modules -->
<script type="module" src="./hello-world.js"></script>
<script>
// Loading with mapped import specifiers can be done with dynamic imports
import('my-module2');
</script>
```

#### Asynchronous import map installation

When using external import maps, import-map-injector must wait for the network request(s) loading external import map(s) to complete before it can install the browser-native import map. This means you cannot use `<script type="module">` and `import()` in your HTML file until after the import-map-injector's `importMapReady` Promise has been resolved:

**Example:**

```html
<script type="injector-importmap">
{
"imports": {
"my-module": "./hello-world.js"
}
}
</script>
<script type="injector-importmap" src="external-url.importmap"></script>
<script src="./import-map-injector.js"></script>
<!--
Since there is an external import map, the import map is installed asynchronously and so we must wait for import map installation
before loading modules with <script type="module"> or import()
-->
<script>
window.importMapInjector.importMapReady.then(() => {
console.log("Ready to dynamically import modules");
import("my-module");
});
</script>
```

### `<script type="module">`

Currently, `<script type="module">` elements in the initial HTML page are not supported, but they will be after #1 is implemented.
File renamed without changes.
38 changes: 38 additions & 0 deletions fixtures/sync.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script type="injector-importmap">
{
"imports": {
"basic": "./overridden-url.js",
"basic2": "./basic2.js"
}
}
</script>
<script type="injector-importmap">
{
"imports": {
"basic": "./basic.js"
}
}
</script>
</head>
<body>
<script src="../lib/import-map-injector.js"></script>
<!-- Loading via mapped specifier in <script> isn't supported yet by browsers, so we load by url instead -->
<script type="module" src="./basic.js"></script>
<script type="module" src="./basic2.js"></script>
<script>
window.importMapInjector.initPromise.then(async () => {
// Load modules via specifier
await Promise.all([import("basic"), import("basic2")]);
console.log(
"Import Map Injector ready, import map specifiers can be loaded properly",
);
});
</script>
</body>
</html>
150 changes: 88 additions & 62 deletions src/import-map-injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,84 +5,110 @@ interface ImportMap {

type SpecifierMap = Record<string, string>;

const jsonPromises: Promise<ImportMap>[] = [];
const importMapJsons: (Promise<ImportMap> | ImportMap)[] = [];

const errPrefix = "import-map-injector:";

document
.querySelectorAll<HTMLScriptElement>("script[type=injector-importmap]")
.forEach((scriptEl) => {
if (scriptEl.src) {
jsonPromises.push(
fetch(scriptEl.src)
.then((r: Response) => {
if (r.ok) {
if (
r.headers.get("content-type").toLowerCase() !==
"application/importmap+json"
) {
throw Error(
`${errPrefix} Import map at url '${scriptEl.src}' does not have the required content-type http response header. Must be 'application/importmap+json'`,
);
}
const injectorImportMaps = document.querySelectorAll<HTMLScriptElement>(
"script[type=injector-importmap]",
);

return r.json();
} else {
injectorImportMaps.forEach((scriptEl) => {
if (scriptEl.src) {
importMapJsons.push(
fetch(scriptEl.src)
.then((r: Response) => {
if (r.ok) {
if (
r.headers.get("content-type").toLowerCase() !==
"application/importmap+json"
) {
throw Error(
`${errPrefix} import map at url '${scriptEl.src}' must respond with a success HTTP status, but responded with HTTP ${r.status} ${r.statusText}`,
`${errPrefix} Import map at url '${scriptEl.src}' does not have the required content-type http response header. Must be 'application/importmap+json'`,
);
}
})
.catch((err) => {
console.error(
`${errPrefix} Error loading import map from URL '${scriptEl.src}'`,

return r.json();
} else {
throw Error(
`${errPrefix} import map at url '${scriptEl.src}' must respond with a success HTTP status, but responded with HTTP ${r.status} ${r.statusText}`,
);
throw err;
}),
);
} else if (scriptEl.textContent.length > 0) {
jsonPromises.push(
Promise.resolve().then(() => JSON.parse(scriptEl.textContent)),
);
} else {
}
})
.catch((err) => {
console.error(
`${errPrefix} Error loading import map from URL '${scriptEl.src}'`,
);
throw err;
}),
);
} else if (scriptEl.textContent.length > 0) {
let json;
try {
json = JSON.parse(scriptEl.textContent);
} catch (err) {
console.error(err);
throw Error(
`${errPrefix} Script with type "injector-importmap" does not contain an importmap`,
`${errPrefix} A <script type="injector-importmap"> element contains invalid JSON`,
);
}
});

importMapJsons.push(json);
} else {
throw Error(
`${errPrefix} Script with type "injector-importmap" does not contain an importmap`,
);
}
});

declare var importMapInjector: {
initPromise: Promise<void>;
};

window.importMapInjector = {
initPromise: Promise.all(jsonPromises)
.then((importMaps) => {
const finalImportMap = { imports: {}, scopes: {} };
for (const importMap of importMaps) {
if (importMap.imports) {
for (let key in importMap.imports) {
finalImportMap.imports[key] = importMap.imports[key];
}
}
const requiresMicroTick = importMapJsons.some(
(json) => json instanceof Promise,
);

if (importMap.scopes) {
for (let key in importMap.scopes) {
finalImportMap.scopes[key] = importMap.scopes[key];
}
}
if (requiresMicroTick) {
window.importMapInjector = {
initPromise: Promise.all(importMapJsons)
.then((importMaps) => {
injectImportMap(importMaps);
})
.catch((err) => {
console.error(
`${errPrefix}: Unable to generate and inject final import map`,
err,
);
throw err;
}),
};
} else {
injectImportMap(importMapJsons as ImportMap[]);
window.importMapInjector = {
// Import map was injected synchronously, so there's nothing to wait on
initPromise: Promise.resolve(),
};
}

function injectImportMap(importMaps: ImportMap[]): void {
const finalImportMap = { imports: {}, scopes: {} };
for (const importMap of importMaps) {
if (importMap.imports) {
for (let key in importMap.imports) {
finalImportMap.imports[key] = importMap.imports[key];
}
}

const finalImportMapScriptEl = document.createElement("script");
finalImportMapScriptEl.type = "importmap";
finalImportMapScriptEl.textContent = JSON.stringify(finalImportMap);
document.head.appendChild(finalImportMapScriptEl);
})
.catch((err) => {
console.error(
`${errPrefix}: Unable to generate and inject final import map`,
err,
);
throw err;
}),
};
if (importMap.scopes) {
for (let key in importMap.scopes) {
finalImportMap.scopes[key] = importMap.scopes[key];
}
}
}

const finalImportMapScriptEl = document.createElement("script");
finalImportMapScriptEl.type = "importmap";
finalImportMapScriptEl.textContent = JSON.stringify(finalImportMap);
document.head.appendChild(finalImportMapScriptEl);
}

0 comments on commit c0757b2

Please sign in to comment.