Skip to content

Commit 3c22b5e

Browse files
authored
Fix installBaseUrl calculation in Node.js when relative URL is passed. (pyodide#5750)
1 parent 2ce0e9b commit 3c22b5e

File tree

3 files changed

+131
-9
lines changed

3 files changed

+131
-9
lines changed

docs/project/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ myst:
2121
when the `lockfileURL` was set to a custom URL.
2222
{pr}`5737`
2323

24+
- {{ Fix }} Fixed a bug in Node.js which providing a relative path to `lockFileURL` parameter of `loadPyodide()` did not work.
25+
{pr}`5750`
26+
2427
## Version 0.28.0
2528

2629
_July 4, 2025_

src/js/load-package.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,7 @@ export class PackageManager {
147147
this.#module = pyodideModule;
148148
this.#installer = new Installer(api, pyodideModule);
149149

150-
// use lockFileURL as the base URL for the packages
151-
// if lockFileURL is relative, use location as the base URL
152-
const lockfileBase =
153-
this.#api.config.lockFileURL.substring(
154-
0,
155-
this.#api.config.lockFileURL.lastIndexOf("/") + 1,
156-
) || globalThis.location?.toString();
157-
150+
const lockfileBase = calculateInstallBaseUrl(this.#api.config.lockFileURL);
158151
if (IN_NODE) {
159152
this.installBaseUrl = this.#api.config.packageCacheDir ?? lockfileBase;
160153
} else {
@@ -457,7 +450,6 @@ export class PackageManager {
457450
}
458451
const lockfilePackage = this.#api.lockfile_packages[pkg.normalizedName];
459452
fileName = lockfilePackage.file_name;
460-
461453
uri = resolvePath(fileName, this.installBaseUrl);
462454
fileSubResourceHash = "sha256-" + base16ToBase64(lockfilePackage.sha256);
463455
} else {
@@ -674,6 +666,24 @@ export function toStringArray(str: string | PyProxy | string[]): string[] {
674666
return str;
675667
}
676668

669+
/**
670+
* Calculates the install base url for the package manager.
671+
* exported for testing
672+
* @param lockFileURL
673+
* @returns the install base url
674+
*/
675+
export function calculateInstallBaseUrl(lockFileURL: string) {
676+
// 1. If the lockfile URL includes a path with slash (file url in Node.js or http url in browser), use the directory of the lockfile URL
677+
// 2. Otherwise, fallback to the current location
678+
// 2.1. In the browser, use `location` to get the current location
679+
// 2.2. In Node.js just use the pwd
680+
return (
681+
lockFileURL.substring(0, lockFileURL.lastIndexOf("/") + 1) ||
682+
globalThis.location?.toString() ||
683+
"."
684+
);
685+
}
686+
677687
export let loadPackage: typeof PackageManager.prototype.loadPackage;
678688
/**
679689
* An object whose keys are the names of the loaded packages and whose values

src/js/test/unit/package-manager.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as chai from "chai";
22
import sinon from "sinon";
33
import { PackageManager, toStringArray } from "../../load-package.ts";
44
import { genMockAPI, genMockModule } from "./test-helper.ts";
5+
import { calculateInstallBaseUrl } from "../../load-package.ts";
56

67
describe("PackageManager", () => {
78
it("should initialize with API and Module", () => {
@@ -120,3 +121,111 @@ describe("getLoadedPackageChannel", () => {
120121
});
121122
});
122123
});
124+
125+
describe("calculateInstallBaseUrl", () => {
126+
let originalLocation: any;
127+
128+
beforeEach(() => {
129+
// Store original location
130+
originalLocation = globalThis.location;
131+
});
132+
133+
afterEach(() => {
134+
// Restore original location
135+
if (originalLocation) {
136+
globalThis.location = originalLocation;
137+
} else {
138+
delete (globalThis as any).location;
139+
}
140+
});
141+
142+
it("Should extract base URL from absolute HTTP URL", () => {
143+
const result = calculateInstallBaseUrl(
144+
"https://cdn.example.com/pyodide/pyodide-lock.json",
145+
);
146+
chai.assert.equal(result, "https://cdn.example.com/pyodide/");
147+
});
148+
149+
it("Should extract base URL from file URL", () => {
150+
const result = calculateInstallBaseUrl(
151+
"file:///tmp/pyodide/pyodide-lock.json",
152+
);
153+
chai.assert.equal(result, "file:///tmp/pyodide/");
154+
});
155+
156+
it("Should extract base URL from relative URL with path", () => {
157+
const result = calculateInstallBaseUrl("./pyodide/pyodide-lock.json");
158+
chai.assert.equal(result, "./pyodide/");
159+
});
160+
161+
it("Should extract base URL from relative URL with parent directory", () => {
162+
const result = calculateInstallBaseUrl("../pyodide/pyodide-lock.json");
163+
chai.assert.equal(result, "../pyodide/");
164+
});
165+
166+
it("Should handle URL with no path component", () => {
167+
const result = calculateInstallBaseUrl("pyodide-lock.json");
168+
chai.assert.equal(result, ".");
169+
});
170+
171+
it("Should handle empty string", () => {
172+
const result = calculateInstallBaseUrl("");
173+
chai.assert.equal(result, ".");
174+
});
175+
176+
it("Should fallback to location when URL has no slash", () => {
177+
// Mock browser location
178+
(globalThis as any).location = {
179+
toString: () => "https://example.com/app/",
180+
};
181+
182+
const result = calculateInstallBaseUrl("pyodide-lock.json");
183+
chai.assert.equal(result, "https://example.com/app/");
184+
});
185+
186+
it("Should fallback to location when URL is empty", () => {
187+
// Mock browser location
188+
(globalThis as any).location = {
189+
toString: () => "https://example.com/app/",
190+
};
191+
192+
const result = calculateInstallBaseUrl("");
193+
chai.assert.equal(result, "https://example.com/app/");
194+
});
195+
196+
it("Should fallback to '.' when no location available", () => {
197+
// Remove location to simulate environment without location
198+
delete (globalThis as any).location;
199+
200+
const result = calculateInstallBaseUrl("pyodide-lock.json");
201+
chai.assert.equal(result, ".");
202+
});
203+
204+
it("Should handle URL with query parameters", () => {
205+
const result = calculateInstallBaseUrl(
206+
"https://cdn.example.com/pyodide/pyodide-lock.json?v=1.0",
207+
);
208+
chai.assert.equal(result, "https://cdn.example.com/pyodide/");
209+
});
210+
211+
it("Should handle URL with hash fragment", () => {
212+
const result = calculateInstallBaseUrl(
213+
"https://cdn.example.com/pyodide/pyodide-lock.json#section",
214+
);
215+
chai.assert.equal(result, "https://cdn.example.com/pyodide/");
216+
});
217+
218+
it("Should handle URL with both query parameters and hash", () => {
219+
const result = calculateInstallBaseUrl(
220+
"https://cdn.example.com/pyodide/pyodide-lock.json?v=1.0#section",
221+
);
222+
chai.assert.equal(result, "https://cdn.example.com/pyodide/");
223+
});
224+
225+
it("Should handle URL with username and password", () => {
226+
const result = calculateInstallBaseUrl(
227+
"https://user:[email protected]/pyodide/pyodide-lock.json",
228+
);
229+
chai.assert.equal(result, "https://user:[email protected]/pyodide/");
230+
});
231+
});

0 commit comments

Comments
 (0)