From b0a224d1a2558fe8e207ea1fdece9b1dac5be0f3 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 8 May 2025 13:06:50 +0200 Subject: [PATCH 1/2] feat: new helper function fetchImportMaps Centralize the code for fetching import maps, which right now is duplicated (poorly) in five different modules. --- README.md | 55 +++++----- lib/helpers/fetch-import-maps.js | 45 ++++++++ lib/helpers/index.js | 2 + test/helpers/fetch-import-maps.test.js | 146 +++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 27 deletions(-) create mode 100644 lib/helpers/fetch-import-maps.js create mode 100644 test/helpers/fetch-import-maps.test.js diff --git a/README.md b/README.md index 9eec364c..1df9daf9 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ can be found here in this repo. Here is how you can use it in your `eik.json`. ```json { - "$schema": "https://raw.githubusercontent.com/eik-lib/common/main/lib/schemas/eikjson.schema.json", - "name": "my-app", - "version": "1.0.0", - "server": "https://eik.store.com", - "files": "./public", - "import-map": ["https://eik.store.com/map/store/v1"] + "$schema": "https://raw.githubusercontent.com/eik-lib/common/main/lib/schemas/eikjson.schema.json", + "name": "my-app", + "version": "1.0.0", + "server": "https://eik.store.com", + "files": "./public", + "import-map": ["https://eik.store.com/map/store/v1"] } ``` @@ -27,7 +27,7 @@ can be found here in this repo. Here is how you can use it in your `eik.json`. `helpers` has utility functions used by several other Eik modules. ```js -import { helpers } from '@eik/common'; +import { helpers } from "@eik/common"; let config = helpers.getDefaults(); ``` @@ -46,6 +46,7 @@ These are the available functions on `helpers`. | `removeLeadingSlash` | | | `resolveFiles` | Uses an Eik JSON "files" definition to resolve files on disk into a data structure. Returns a list of [ResolvedFile](https://github.com/eik-lib/common/blob/main/lib/classes/resolved-files.js). | | `configStore` | Collection of helper methods for reading and writing Eik configuration files. | +| `fetchImportMaps` | Helper to get import maps (array of URLs) with some common error handling. | #### localAssets @@ -54,8 +55,8 @@ Sets up asset routes for local development. Mounted paths match those on Eik ser Given this server and `eik.json`, the following routes would be added to your app. ```js -import { helpers } from '@eik/common'; -import express from 'express'; +import { helpers } from "@eik/common"; +import express from "express"; let app = express(); @@ -64,14 +65,14 @@ await helpers.localAssets(app); ```json { - "name": "my-app", - "version": "1.0.0", - "server": "https://eik.store.com", - "files": { - "esm.js": "./assets/esm.js", - "esm.css": "./assets/esm.css", - "/": "./assets/**/*.map" - } + "name": "my-app", + "version": "1.0.0", + "server": "https://eik.store.com", + "files": { + "esm.js": "./assets/esm.js", + "esm.css": "./assets/esm.css", + "/": "./assets/**/*.map" + } } ``` @@ -89,23 +90,23 @@ You can check a value against the schema for `eik.json` as a whole, or for indiv values in the schema. ```js -import { schemas } from '@eik/common'; +import { schemas } from "@eik/common"; let { error, value } = schemas.validate.eikJSON(eikConfig); if (error) { - // fallback + // fallback } ``` If you prefer, you can use the `assert` API which throws on error. ```js -import { schemas } from '@eik/common'; +import { schemas } from "@eik/common"; try { - schemas.assert.eikJSON(eikConfig); + schemas.assert.eikJSON(eikConfig); } catch { - // fallback + // fallback } ``` @@ -127,14 +128,14 @@ These are the available functions on `schemas.validate` and `schemas.assert`. `stream` has functions to check that a value is a Stream. ```js -import { stream } from '@eik/common'; +import { stream } from "@eik/common"; if (stream.isStream(maybeStream)) { - // yup, it's a Stream + // yup, it's a Stream } if (stream.isReadableStream(maybeReadableStream)) { - // yup, it's a ReadableStream + // yup, it's a ReadableStream } ``` @@ -144,9 +145,9 @@ if (stream.isReadableStream(maybeReadableStream)) { Where possible, prefer using the [`schemas` API](#schemas). ```js -import { validators } from '@eik/common'; +import { validators } from "@eik/common"; -let alias = validators.alias('1'); +let alias = validators.alias("1"); ``` These are the available functions on `validators`. diff --git a/lib/helpers/fetch-import-maps.js b/lib/helpers/fetch-import-maps.js new file mode 100644 index 00000000..dc020281 --- /dev/null +++ b/lib/helpers/fetch-import-maps.js @@ -0,0 +1,45 @@ +/** + * @typedef {object} ImportMap + * @property {Record} imports + */ + +/** + * @param {string[]} urls + * @returns {Promise} + */ +export async function fetchImportMaps(urls = []) { + try { + const maps = urls.map(async (map) => { + const response = await fetch(map); + + if (response.status === 404) { + throw new Error("Import map could not be found on server"); + } else if (response.status >= 400 && response.status < 500) { + throw new Error("Server rejected client request"); + } else if (response.status >= 500) { + throw new Error("Server error"); + } + + let contentType = response.headers.get("content-type"); + if (!contentType.startsWith("application/json")) { + const content = await response.text(); + if (content.length === 0) { + throw new Error( + `${map} did not return JSON, got an empty response. HTTP status: ${response.status}`, + ); + } + throw new Error( + `${map} did not return JSON, got: ${content}. HTTP status: ${response.status}`, + ); + } + + const json = await response.json(); + return /** @type {ImportMap}*/ (json); + }); + return await Promise.all(maps); + } catch (err) { + throw new Error( + `Unable to load import map file from server: ${err.message}`, + ); + } +} diff --git a/lib/helpers/index.js b/lib/helpers/index.js index 6e20b67c..3a1928fa 100644 --- a/lib/helpers/index.js +++ b/lib/helpers/index.js @@ -1,6 +1,7 @@ import localAssets from "./local-assets.js"; import getDefaults from "./get-defaults.js"; import configStore from "./config-store.js"; +import { fetchImportMaps } from "./fetch-import-maps.js"; import typeSlug from "./type-slug.js"; import typeTitle from "./type-title.js"; import resolveFiles from "./resolve-files.js"; @@ -15,6 +16,7 @@ export default { localAssets, getDefaults, configStore, + fetchImportMaps, typeSlug, typeTitle, addTrailingSlash, diff --git a/test/helpers/fetch-import-maps.test.js b/test/helpers/fetch-import-maps.test.js new file mode 100644 index 00000000..8990442c --- /dev/null +++ b/test/helpers/fetch-import-maps.test.js @@ -0,0 +1,146 @@ +import fastify from "fastify"; +import tap from "tap"; +import { fetchImportMaps } from "../../lib/helpers/fetch-import-maps.js"; + +const app = fastify({ + keepAliveTimeout: 20, + forceCloseConnections: true, +}); + +app.get("/map/lit/v3", (request, reply) => { + /** @type {import("../../lib/helpers/fetch-import-maps.js").ImportMap} */ + const map = { + imports: { + lit: "https://assets.example.com/npm/lit/v3/lit.min.js", + }, + }; + reply.send(map); +}); + +app.get("/map/react/v19", (request, reply) => { + /** @type {import("../../lib/helpers/fetch-import-maps.js").ImportMap} */ + const map = { + imports: { + react: "https://assets.example.com/npm/react/v19/react.min.js", + "react-dom": + "https://assets.example.com/npm/react-dom/v19/react-dom.min.js", + }, + }; + reply.send(map); +}); + +app.get("/map/rejected-response/v1", (request, reply) => { + reply.status(403).send(); +}); + +app.get("/map/server-error/v1", (request, reply) => { + reply.status(500).send(); +}); + +app.get("/map/empty-response/v1", (request, reply) => { + reply.send(""); +}); + +app.get("/map/text-response/v1", (request, reply) => { + reply.send("

Hello, World

"); +}); + +/** @type {string} */ +let address; + +tap.before(async () => { + address = await app.listen({ + host: "0.0.0.0", + port: 50255, + }); +}); + +tap.teardown(() => app.close()); + +tap.test("returns the expected maps", async (t) => { + const result = await fetchImportMaps([ + `${address}/map/lit/v3`, + `${address}/map/react/v19`, + ]); + t.match(result, [ + { + imports: { + lit: "https://assets.example.com/npm/lit/v3/lit.min.js", + }, + }, + { + imports: { + react: "https://assets.example.com/npm/react/v19/react.min.js", + "react-dom": + "https://assets.example.com/npm/react-dom/v19/react-dom.min.js", + }, + }, + ]); + t.end(); +}); + +tap.test("returns an error if an import map could not be found", async (t) => { + try { + await fetchImportMaps([`${address}/map/does-not-exist/v1`]); + t.fail("Expected to throw"); + } catch (e) { + t.match(e.message, "could not be found"); + t.pass(); + } +}); + +tap.test("returns an error if an import map could not be found", async (t) => { + try { + await fetchImportMaps([`${address}/map/does-not-exist/v1`]); + t.fail("Expected to throw"); + } catch (e) { + t.match(e.message, "could not be found"); + t.pass(); + } +}); + +tap.test("returns an error if server says no", async (t) => { + try { + await fetchImportMaps([`${address}/map/rejected-response/v1`]); + t.fail("Expected to throw"); + } catch (e) { + t.match(e.message, "rejected client request"); + t.pass(); + } +}); + +tap.test("returns an error if server is down", async (t) => { + try { + await fetchImportMaps([`${address}/map/server-error/v1`]); + t.fail("Expected to throw"); + } catch (e) { + t.match(e.message, "Server error"); + t.pass(); + } +}); + +tap.test( + "returns an error if an import map fetch returns an empty result", + async (t) => { + try { + await fetchImportMaps([`${address}/map/empty-response/v1`]); + t.fail("Expected to throw"); + } catch (e) { + t.match(e.message, "got an empty response"); + t.pass(); + } + }, +); + +tap.test( + "returns an error if an import map fetch returns non-JSON content", + async (t) => { + try { + await fetchImportMaps([`${address}/map/text-response/v1`]); + t.fail("Expected to throw"); + } catch (e) { + t.match(e.message, "did not return JSON, got"); + t.pass(); + } + }, +); From 72228cfc606fdc841e944e18eeb95721063c7273 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 8 May 2025 13:37:20 +0200 Subject: [PATCH 2/2] test: remove duplicate test --- test/helpers/fetch-import-maps.test.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/helpers/fetch-import-maps.test.js b/test/helpers/fetch-import-maps.test.js index 8990442c..ef257f3b 100644 --- a/test/helpers/fetch-import-maps.test.js +++ b/test/helpers/fetch-import-maps.test.js @@ -89,16 +89,6 @@ tap.test("returns an error if an import map could not be found", async (t) => { } }); -tap.test("returns an error if an import map could not be found", async (t) => { - try { - await fetchImportMaps([`${address}/map/does-not-exist/v1`]); - t.fail("Expected to throw"); - } catch (e) { - t.match(e.message, "could not be found"); - t.pass(); - } -}); - tap.test("returns an error if server says no", async (t) => { try { await fetchImportMaps([`${address}/map/rejected-response/v1`]);