Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
149 changes: 74 additions & 75 deletions src/add.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test";

import { ImportMap } from "./importmap.ts";
import { addImport } from "./add.ts";
import { addImport, setFetcher } from "./add.ts";

describe("addImport", () => {
setDefaultTimeout(15000);
Expand Down Expand Up @@ -75,54 +75,53 @@ describe("addImport", () => {
const im = new ImportMap();
im.imports.peer = "https://esm.sh/peer@1.0.0/es2022/peer.mjs";

const fetchMock = spyOn(globalThis, "fetch").mockImplementation(
(async (input: unknown) => {
const url = input instanceof URL ? input.toString() : input instanceof Request ? input.url : String(input);
if (url === "https://esm.sh/pkg@1?meta" || url === "https://esm.sh/pkg@1.0.0?meta") {
return new Response(
JSON.stringify({
name: "pkg",
version: "1.0.0",
module: "/pkg@1.0.0/es2022/pkg.mjs",
integrity: "sha384-pkg",
exports: [],
imports: [],
peerImports: ["/peer@^2.0.0"],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url === "https://esm.sh/*pkg@1?meta" || url === "https://esm.sh/*pkg@1.0.0?meta") {
return new Response(
JSON.stringify({
name: "pkg",
version: "1.0.0",
module: "/pkg@1.0.0/es2022/pkg.mjs",
integrity: "sha384-pkg-ext",
exports: [],
imports: [],
peerImports: ["/peer@^2.0.0"],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
}) as typeof fetch,
);
setFetcher(async (url) => {
const text = url.toString();
if (text === "https://esm.sh/pkg@1?meta" || text === "https://esm.sh/pkg@1.0.0?meta") {
return new Response(
JSON.stringify({
name: "pkg",
version: "1.0.0",
module: "/pkg@1.0.0/es2022/pkg.mjs",
integrity: "sha384-pkg",
exports: [],
imports: [],
peerImports: ["/peer@^2.0.0"],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (text === "https://esm.sh/*pkg@1?meta" || text === "https://esm.sh/*pkg@1.0.0?meta") {
return new Response(
JSON.stringify({
name: "pkg",
version: "1.0.0",
module: "/pkg@1.0.0/es2022/pkg.mjs",
integrity: "sha384-pkg-ext",
exports: [],
imports: [],
peerImports: ["/peer@^2.0.0"],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
});

const warn = spyOn(console, "warn").mockImplementation(() => {});

await addImport(im, "pkg@1");

expect(warn).toHaveBeenCalled();
expect(
warn.mock.calls.some(
([msg]) => typeof msg === "string" && msg.includes("incorrect peer dependency(unmeet"),
),
).toBeTrue();

warn.mockRestore();
fetchMock.mockRestore();
try {
Comment on lines +78 to +112
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In both mocked tests, setFetcher is called before the try/finally block that restores the fetcher. In the "warns on unmet peer dependency" test, setFetcher (line 78) and spyOn(console, "warn") (line 111) both run before the try block (line 112). If spyOn throws, the mock fetcher is never restored and will leak into subsequent tests. Similarly, in the "removes scope specifiers..." test, setFetcher (line 138) runs before the try block (line 157). The setFetcher call should be moved inside the try block (or placed at the very top of it), just as spyOn is, so that the finally cleanup always runs after the setup.

Copilot uses AI. Check for mistakes.
await addImport(im, "pkg@1");

expect(warn).toHaveBeenCalled();
expect(
warn.mock.calls.some(
([msg]) => typeof msg === "string" && msg.includes("incorrect peer dependency(unmeet"),
),
).toBeTrue();
} finally {
warn.mockRestore();
setFetcher(globalThis.fetch);
}
});

test("removes scope specifiers duplicated in imports except exact-version scopes", async () => {
Expand All @@ -136,33 +135,33 @@ describe("addImport", () => {
shared: "https://esm.sh/shared@1.0.0/es2022/shared.mjs",
};

const fetchMock = spyOn(globalThis, "fetch").mockImplementation(
(async (input: unknown) => {
const url = input instanceof URL ? input.toString() : input instanceof Request ? input.url : String(input);
if (url === "https://esm.sh/pkg2@1?meta" || url === "https://esm.sh/pkg2@1.0.0?meta") {
return new Response(
JSON.stringify({
name: "pkg2",
version: "1.0.0",
module: "/pkg2@1.0.0/es2022/pkg2.mjs",
integrity: "sha384-pkg2",
exports: [],
imports: [],
peerImports: [],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
}) as typeof fetch,
);

await addImport(im, "pkg2@1");

expect(im.scopes["https://esm.sh/"]!.shared).toBeUndefined();
expect(im.scopes["https://esm.sh/"]!.onlyInScope).toBeString();
expect(im.scopes["https://esm.sh/pkg2@1.0.0/"]!.shared).toBeString();

fetchMock.mockRestore();
setFetcher(async (url) => {
const text = url.toString();
if (text === "https://esm.sh/pkg2@1?meta" || text === "https://esm.sh/pkg2@1.0.0?meta") {
return new Response(
JSON.stringify({
name: "pkg2",
version: "1.0.0",
module: "/pkg2@1.0.0/es2022/pkg2.mjs",
integrity: "sha384-pkg2",
exports: [],
imports: [],
peerImports: [],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
});

try {
await addImport(im, "pkg2@1");

expect(im.scopes["https://esm.sh/"]!.shared).toBeUndefined();
expect(im.scopes["https://esm.sh/"]!.onlyInScope).toBeString();
expect(im.scopes["https://esm.sh/pkg2@1.0.0/"]!.shared).toBeString();
} finally {
setFetcher(globalThis.fetch);
}
});
});
84 changes: 54 additions & 30 deletions src/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type ImportMeta = ImportInfo & {
peerImports: string[];
};

type Fetcher = (url: string | URL) => Promise<Response>;

const KNOWN_TARGETS = new Set([
"es2015",
"es2016",
Expand Down Expand Up @@ -84,13 +86,13 @@ async function addImportImpl(
target: string,
noSRI: boolean,
): Promise<void> {
const markedSpecifier = `${specifierOf(imp)}${SPECIFIER_MARK_SEPARATOR}${imp.version}`;
const markedSpecifier = specifierOf(imp) + SPECIFIER_MARK_SEPARATOR + imp.version;
if (mark.has(markedSpecifier)) {
return;
}
mark.add(markedSpecifier);

const cdnScopeKey = `${cdnOrigin}/`;
const cdnScopeKey = cdnOrigin + "/";
const cdnScopeImports = importMap.scopes?.[cdnScopeKey];

const imports = indirect ? (targetImports ?? ensureScope(importMap, cdnScopeKey)) : importMap.imports;
Expand Down Expand Up @@ -126,7 +128,7 @@ async function addImportImpl(
const depSpecifier = specifierOf(depImport);
const existingUrl = importMap.imports[depSpecifier] ?? importMap.scopes?.[cdnScopeKey]?.[depSpecifier];
let scopedTargetImports = targetImports;
if (existingUrl?.startsWith(`${cdnOrigin}/`)) {
if (existingUrl?.startsWith(cdnOrigin + "/")) {
const existingImport = parseEsmPath(existingUrl);
const existingVersion = valid(existingImport.version);
if (existingVersion && depImport.version === existingImport.version) {
Expand All @@ -138,11 +140,11 @@ async function addImportImpl(
}
if (isPeer) {
console.warn(
`incorrect peer dependency(unmeet ${depImport.version}): ${depImport.name}@${existingVersion}`,
"incorrect peer dependency(unmeet " + depImport.version + "): " + depImport.name + "@" + existingVersion,
);
return;
}
const scope = `${cdnOrigin}/${esmSpecifierOf(imp)}/`;
const scope = cdnOrigin + "/" + esmSpecifierOf(imp) + "/";
scopedTargetImports = ensureScope(importMap, scope);
}
}
Expand Down Expand Up @@ -226,11 +228,11 @@ function parseImportSpecifier(specifier: string): ImportInfo {
[packageAndVersion, imp.subPath] = splitByFirst(source, "/");
[imp.name, imp.version] = splitByFirst(packageAndVersion, "@");
if (scopeName) {
imp.name = `${scopeName}/${imp.name}`;
imp.name = scopeName + "/" + imp.name;
}

if (!imp.name) {
throw new Error(`invalid package name or version: ${specifier}`);
throw new Error("invalid package name or version: " + specifier);
}

return imp;
Expand All @@ -245,20 +247,24 @@ function normalizeTarget(target: string | undefined): string {

function getCdnOrigin(cdn: string | undefined): string {
if (cdn && (cdn.startsWith("https://") || cdn.startsWith("http://"))) {
return cdn.replace(/\/+$/, "");
if (cdn.endsWith("/")) {
// remove trailing slash
return cdn.slice(0, -1);
}
return cdn;
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original implementation used cdn.replace(/\/+$/, "") which strips one or more consecutive trailing slashes. The new implementation uses cdn.endsWith("/") and cdn.slice(0, -1), which only removes a single trailing slash. A CDN URL like https://my-cdn.example.com/// would previously be normalized to https://my-cdn.example.com, but with the new code it becomes https://my-cdn.example.com//, causing malformed request URLs. The old regex-based approach should be preserved, or the slice should be applied in a loop (or use trimEnd-based approach) to match the original behavior.

Suggested change
if (cdn.endsWith("/")) {
// remove trailing slash
return cdn.slice(0, -1);
}
return cdn;
// remove one or more trailing slashes to normalize the origin
return cdn.replace(/\/+$/, "");

Copilot uses AI. Check for mistakes.
}
return "https://esm.sh";
}

function specifierOf(imp: ImportInfo): string {
const prefix = imp.github ? "gh:" : imp.jsr ? "jsr:" : "";
return `${prefix}${imp.name}${imp.subPath ? `/${imp.subPath}` : ""}`;
return prefix + imp.name + (imp.subPath ? "/" + imp.subPath : "");
}

function esmSpecifierOf(imp: ImportMeta): string {
const prefix = imp.github ? "gh/" : imp.jsr ? "jsr/" : "";
const external = hasExternalImports(imp) ? "*" : "";
return `${prefix}${external}${imp.name}@${imp.version}`;
return prefix + external + imp.name + "@" + imp.version;
}

function registryPrefix(imp: ImportInfo): string {
Expand All @@ -276,55 +282,66 @@ function hasExternalImports(meta: ImportMeta): boolean {
return true;
}
for (const dep of meta.imports) {
if (!dep.startsWith("/node/") && !dep.startsWith(`/${meta.name}@`)) {
if (!dep.startsWith("/node/") && !dep.startsWith("/" + meta.name + "@")) {
return true;
}
}
return false;
}

function moduleUrlOf(cdnOrigin: string, target: string, imp: ImportMeta): string {
let url = `${cdnOrigin}/${esmSpecifierOf(imp)}/${target}/`;
let url = cdnOrigin + "/" + esmSpecifierOf(imp) + "/" + target + "/";
if (imp.subPath) {
if (imp.dev || imp.subPath === "jsx-dev-runtime") {
url += `${imp.subPath}.development.mjs`;
url += imp.subPath + ".development.mjs";
} else {
url += `${imp.subPath}.mjs`;
url += imp.subPath + ".mjs";
}
return url;
}

const fileName = imp.name.includes("/") ? imp.name.split("/").at(-1)! : imp.name;
return `${url}${fileName}.mjs`;
return url + fileName + ".mjs";
}

let fetcher: Fetcher = globalThis.fetch;

/**
* Set the fetcher to use for fetching import meta.
*
* @param fetcher - The fetcher to use.
*/
export function setFetcher(f: Fetcher): void {
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc @param tag says @param fetcher but the actual parameter is named f. This mismatch means documentation tooling will not correctly associate the parameter description with the actual parameter. The parameter should be renamed from f to fetcher to match the docstring and to be more descriptive.

Copilot uses AI. Check for mistakes.
fetcher = f;
}

async function fetchImportMeta(cdnOrigin: string, imp: ImportInfo, target: string): Promise<ImportMeta> {
const star = imp.external ? "*" : "";
const version = imp.version ? `@${imp.version}` : "";
const subPath = imp.subPath ? `/${imp.subPath}` : "";
const targetQuery = target !== "es2022" ? `&target=${encodeURIComponent(target)}` : "";
const url = `${cdnOrigin}/${star}${registryPrefix(imp)}${imp.name}${version}${subPath}?meta${targetQuery}`;
const version = imp.version ? "@" + imp.version : "";
const subPath = imp.subPath ? "/" + imp.subPath : "";
const targetQuery = target !== "es2022" ? "&target=" + encodeURIComponent(target) : "";
const url = cdnOrigin + "/" + star + registryPrefix(imp) + imp.name + version + subPath + "?meta" + targetQuery;

const cached = META_CACHE.get(url);
if (cached) {
return cached;
}

const pending = (async () => {
const res = await fetch(url);
const res = await fetcher(url);
if (res.status === 404) {
throw new Error(`package not found: ${imp.name}${version}${subPath}`);
throw new Error("package not found: " + imp.name + version + subPath);
}
if (!res.ok) {
throw new Error(`unexpected http status ${res.status}: ${await res.text()}`);
throw new Error("unexpected http status " + res.status + ": " + await res.text());
}

const bodyText = await res.text();
let data: Partial<ImportMeta>;
try {
data = JSON.parse(bodyText) as Partial<ImportMeta>;
} catch {
throw new Error(`invalid meta response from ${url}: ${bodyText.slice(0, 200)}`);
} catch (error) {
throw new Error("invalid meta response from " + url + ": " + error.message);
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error variable in a catch clause is typed as unknown in TypeScript (with useUnknownInCatchVariables, which is on by default in strict mode and in Bun's TypeScript). Accessing error.message directly will cause a TypeScript type error. The previous code avoided this by not capturing the error variable at all (catch {), and instead included bodyText.slice(0, 200) in the error message, which provided useful debugging context. The new code should either cast the error to Error using error instanceof Error ? error.message : String(error), or restore the previous bodyText.slice(0, 200) approach.

Suggested change
throw new Error("invalid meta response from " + url + ": " + error.message);
const message = error instanceof Error ? error.message : String(error);
throw new Error("invalid meta response from " + url + ": " + message);

Copilot uses AI. Check for mistakes.
}
return {
name: data.name ?? imp.name,
Expand Down Expand Up @@ -390,7 +407,7 @@ function parseEsmPath(pathnameOrUrl: string): ImportInfo {
} else if (pathnameOrUrl.startsWith("/")) {
pathname = splitByFirst(splitByFirst(pathnameOrUrl, "#")[0], "?")[0];
} else {
throw new Error(`invalid pathname or url: ${pathnameOrUrl}`);
throw new Error("invalid pathname or url: " + pathnameOrUrl);
}

const imp: ImportInfo = {
Expand All @@ -413,20 +430,20 @@ function parseEsmPath(pathnameOrUrl: string): ImportInfo {

const segs = pathname.split("/").filter(Boolean);
if (segs.length === 0) {
throw new Error(`invalid pathname: ${pathnameOrUrl}`);
throw new Error("invalid pathname: " + pathnameOrUrl);
}

if (segs[0]!.startsWith("@")) {
if (!segs[1]) {
throw new Error(`invalid pathname: ${pathnameOrUrl}`);
throw new Error("invalid pathname: " + pathnameOrUrl);
}
const [name, version] = splitByLast(segs[1]!, "@");
imp.name = `${segs[0]}/${name}`.replace(/^\*/, "");
imp.name = trimLeadingStar(segs[0] + "/" + name);
imp.version = version;
segs.splice(0, 2);
} else {
const [name, version] = splitByLast(segs[0]!, "@");
imp.name = name.replace(/^\*/, "");
imp.name = trimLeadingStar(name);
imp.version = version;
segs.splice(0, 1);
}
Expand All @@ -447,7 +464,7 @@ function parseEsmPath(pathnameOrUrl: string): ImportInfo {
subPath = subPath.slice(0, -12);
imp.dev = true;
}
if (subPath.includes("/") || (subPath !== imp.name && !imp.name.endsWith(`/${subPath}`))) {
if (subPath.includes("/") || (subPath !== imp.name && !imp.name.endsWith("/" + subPath))) {
imp.subPath = subPath;
}
} else {
Expand All @@ -458,6 +475,13 @@ function parseEsmPath(pathnameOrUrl: string): ImportInfo {
return imp;
}

function trimLeadingStar(value: string): string {
if (value.startsWith("*")) {
return value.slice(1);
}
return value;
}

function splitByFirst(value: string, separator: string): [string, string] {
const idx = value.indexOf(separator);
if (idx < 0) {
Expand Down
Loading