Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 28 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
```

Expand All @@ -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();
```
Expand All @@ -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

Expand All @@ -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();

Expand All @@ -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"
}
}
```

Expand All @@ -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
}
```

Expand All @@ -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
}
```

Expand All @@ -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`.
Expand Down
45 changes: 45 additions & 0 deletions lib/helpers/fetch-import-maps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* @typedef {object} ImportMap
* @property {Record<string, string>} imports
*/

/**
* @param {string[]} urls
* @returns {Promise<ImportMap[]>}
*/
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}`,
);
}
}
2 changes: 2 additions & 0 deletions lib/helpers/index.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +16,7 @@ export default {
localAssets,
getDefaults,
configStore,
fetchImportMaps,
typeSlug,
typeTitle,
addTrailingSlash,
Expand Down
136 changes: 136 additions & 0 deletions test/helpers/fetch-import-maps.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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("<h1>Hello, World</h1>");
});

/** @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 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();
}
},
);