diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb5d03b --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# modulepreload + +Inject modulepreload tags into HTML to remove the waterfall problem in loading web modules. + +```sh +$ npm install --save-dev modulepreload +$ npx modulepreload inject -w index.html +``` + +Before injection: +```html + + + + Example + + + + + +``` + +After injection: + +```html + + + + Example + + + + + + + +``` \ No newline at end of file diff --git a/cli.js b/cli.js new file mode 100755 index 0000000..0b84132 --- /dev/null +++ b/cli.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import cac from 'cac' +import { inject } from './src/inject.js' + +const cli = cac('module_preload') + +cli.option('--root ', 'Root') +cli.option('--no-fetch', 'No fetch') +cli.option('--w', 'Write') +cli.option('--o ', 'Out') + +cli.command('inject [...files]', 'Inject files') + .action(async (files, options) => { + const { root, fetch: doFetch } = options + files.forEach(async filePath => { + const defaultOut = options.w ? filePath : null + const out = options.o ? options.o : defaultOut + await inject(filePath, { root, out, noFetch: !doFetch }) + }) + }) + +cli.help() +cli.parse() + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1bee928 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,183 @@ +{ + "name": "@dpikt/module-preload", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@dpikt/module-preload", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@import-maps/resolve": "^2.0.0", + "cac": "^6.7.14", + "es-module-lexer": "^1.4.1", + "htmlparser2": "^9.0.0" + } + }, + "node_modules/@import-maps/resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@import-maps/resolve/-/resolve-2.0.0.tgz", + "integrity": "sha512-RwzRTpmrrS6Q1ZhQExwuxJGK1Wqhv4stt+OF2JzS+uawewpwNyU7EJL1WpBex7aDiiGLs4FsXGkfUBdYuX7xiQ==" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==" + }, + "node_modules/htmlparser2": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.0.0.tgz", + "integrity": "sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + } + }, + "dependencies": { + "@import-maps/resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@import-maps/resolve/-/resolve-2.0.0.tgz", + "integrity": "sha512-RwzRTpmrrS6Q1ZhQExwuxJGK1Wqhv4stt+OF2JzS+uawewpwNyU7EJL1WpBex7aDiiGLs4FsXGkfUBdYuX7xiQ==" + }, + "cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==" + }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==" + }, + "htmlparser2": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.0.0.tgz", + "integrity": "sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2b3ccf7 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "modulepreload", + "version": "1.0.0", + "description": "", + "main": "cli.js", + "bin": { + "modulepreload": "./cli.js" + }, + "scripts": { + "test": "node --test" + }, + "type": "module", + "author": "", + "license": "MIT", + "dependencies": { + "@import-maps/resolve": "^2.0.0", + "cac": "^6.7.14", + "es-module-lexer": "^1.4.1", + "htmlparser2": "^9.0.0" + } +} \ No newline at end of file diff --git a/src/inject.js b/src/inject.js new file mode 100644 index 0000000..b6202f1 --- /dev/null +++ b/src/inject.js @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import url from "node:url"; +import { parse } from "es-module-lexer"; +import { Parser } from "htmlparser2"; +import { resolve, parseFromString } from '@import-maps/resolve' + +async function getImports(script) { + const [imports] = await parse(script); + return imports.map((imp) => imp.n); +} + +class ImportCollector { + constructor({ imports, baseUrl, importMap, noFetch }) { + this.imports = imports; + this.baseUrl = baseUrl; + this.dependencies = new Set(); + this.noFetch = noFetch + this.importMap = importMap + } + async visit(specifier, parent) { + let resolvedImport = null + if (parent.protocol === "file:") { + const resolved = resolve(specifier, this.importMap, parent); + resolvedImport = resolved.resolvedImport + if (specifier.startsWith('/')) { + resolvedImport = new URL('.' + specifier, this.baseUrl) + } + } else if (parent.protocol.startsWith("http")) { + resolvedImport = new URL(specifier, parent.origin) + } + if (!resolvedImport) { + throw 'could not resolve import: ' + specifier + }; + if (this.dependencies.has(resolvedImport.href)) return; + this.dependencies.add(resolvedImport.href); + if (resolvedImport.protocol === "file:") { + try { + const contents = await fs.promises.readFile(resolvedImport, "utf8"); + const deps = await getImports(contents); + await Promise.all( + deps.map((dep) => this.visit(dep, resolvedImport)) + ); + } catch (e) { + console.warn('WARNING: could not read file: ' + resolvedImport.href + ' - skipping') + } + } else if (resolvedImport.protocol.startsWith("http")) { + if (!this.noFetch) { + const contents = await fetch(resolvedImport).then(res => res.text()) + const deps = await getImports(contents); + await Promise.all( + deps.map((dep) => this.visit(dep, resolvedImport)) + ); + } + } + } + async collect() { + const parent = new URL('./index.js', this.baseUrl) + await Promise.all(this.imports.map((entry) => this.visit(entry, parent))); + return [...this.dependencies].map((dep) => dep.replace(this.baseUrl.href, "/")); + } +} + +async function parseHtml(contents) { + const scripts = []; + let inScript = false; + let inImportMap = false; + let importMapString = "" + const parser = new Parser({ + onopentag(name, attributes) { + if (name === "script" && attributes.type === "module") { + inScript = true; + scripts.unshift(""); + } + if (name === "script" && attributes.type === "importmap") { + inImportMap = true; + } + }, + ontext(text) { + if (inScript) { + scripts[0] += text; + } + if (inImportMap) { + importMapString += text; + } + }, + onclosetag(tagname) { + if (tagname === "script") { + inScript = false; + inImportMap = false; + } + }, + }); + parser.write(contents); + parser.end(); + const imports = new Set() + for (let script of scripts) { + const deps = await getImports(script); + deps.forEach((d) => imports.add(d)); + } + return { imports: [...imports], importMapString: importMapString || '{}' }; +} + +export async function getDependencies(contents, baseUrl, { noFetch } = {}) { + if (!baseUrl) { + throw new Error('baseUrl is required') + } + if (!baseUrl.href.endsWith('/')) { + baseUrl.href += '/' + } + const { importMapString, imports } = await parseHtml(contents); + const importMap = parseFromString(importMapString, baseUrl); + const collector = new ImportCollector({ imports, baseUrl, importMap, noFetch }); + return collector.collect(); +} + +export function injectPreloads(contents, dependencies) { + let preloads = ""; + for (const dep of dependencies) { + preloads += `\n`; + } + return contents.replace("", `${preloads}`); +} + +export async function link(htmlContentsOrUrl, { baseUrl: providedBaseUrl, noFetch } = {}) { + let html = htmlContentsOrUrl; + let baseUrl = providedBaseUrl; + if (htmlContentsOrUrl instanceof URL) { + html = await fs.promises.readFile(htmlContentsOrUrl, "utf8"); + baseUrl = new URL('./', htmlContentsOrUrl) + } + const dependencies = await getDependencies(html, baseUrl, { noFetch }); + return injectPreloads(html, dependencies); +} + +export async function inject(htmlPath, { out, root, noFetch } = {}) { + const htmlUrl = url.pathToFileURL(htmlPath) + const baseUrl = root ? new URL(root) : null + const newHtml = await link(htmlUrl, { baseUrl, noFetch }) + if (out) { + await fs.promises.writeFile(out, newHtml); + } else { + process.stdout.write(newHtml) + } +} diff --git a/tests/fixtures/absolute-dep.js b/tests/fixtures/absolute-dep.js new file mode 100644 index 0000000..ac02139 --- /dev/null +++ b/tests/fixtures/absolute-dep.js @@ -0,0 +1,3 @@ +export function foo() { + return "foo"; +} \ No newline at end of file diff --git a/tests/fixtures/index.html b/tests/fixtures/index.html new file mode 100644 index 0000000..f4ee64b --- /dev/null +++ b/tests/fixtures/index.html @@ -0,0 +1,22 @@ + + + + + My HTML File + + + + +

Hello, world!

+ + + + \ No newline at end of file diff --git a/tests/fixtures/js/dep.js b/tests/fixtures/js/dep.js new file mode 100644 index 0000000..fef4957 --- /dev/null +++ b/tests/fixtures/js/dep.js @@ -0,0 +1,7 @@ +import { world } from "./secondary-dep.js"; +import { foo } from "/absolute-dep.js"; + +// Export function hello +export function hello() { + return "hello" + world(); +} \ No newline at end of file diff --git a/tests/fixtures/js/secondary-dep.js b/tests/fixtures/js/secondary-dep.js new file mode 100644 index 0000000..6af38cf --- /dev/null +++ b/tests/fixtures/js/secondary-dep.js @@ -0,0 +1,6 @@ +import jquery from 'jquery' + +// Export function world +export function world() { + return "world"; +} \ No newline at end of file diff --git a/tests/inject.test.js b/tests/inject.test.js new file mode 100644 index 0000000..9cafbf4 --- /dev/null +++ b/tests/inject.test.js @@ -0,0 +1,7 @@ +import { link } from '../src/inject.js'; +import test from 'node:test'; + +test('inject', async (t) => { + const htmlUrl = new URL('./fixtures/index.html', import.meta.url) + console.log(await link(htmlUrl, { noFetch: true })) +});