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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Asynchronously loads JSON from a file.
- `options` (object, optional):
- `base` (string | URL, optional): Base URL for resolving relative paths. Defaults to the caller's file URL.
- `validate` (function, optional): Validation function called with the parsed JSON. Throws if validation fails.
- `reviver` (function, optional): Reviver function passed to `JSON.parse`.
- `reviver` (function, optional): Reviver function passed to `JSON.parse`. For a module loaded through `import()` that has no default export, `reviver` and `validate` receive a plain-object copy of its exports.
- `type` (string, optional): Import attribute type used for the `import()` attempts. Defaults to `"json"`; the file-system fallback only runs for `"json"`.
- `fallback` (string | URL, optional): A second file to load when `input` cannot be read or parsed. A `validate` failure on `input` throws rather than falling back.

Expand Down
64 changes: 30 additions & 34 deletions src/wisp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,33 @@ import { resolveUrlFromCaller } from "./lib/resolve-from-caller.mjs";
* @param {*} v - The value to clone.
* @returns {*} The cloned value.
*/
function deepClone(v) {
return typeof globalThis.structuredClone === "function" ? globalThis.structuredClone(v) : JSON.parse(JSON.stringify(v));
}

/**
* Deep clones a value using structuredClone if available, otherwise JSON.parse/stringify.
* Picks the value a loaded module provides: its default export, or the namespace when it has none.
* With a reviver or validate the value is copied so the module cache is never mutated; a namespace
* object cannot be structured-cloned, so it is first copied to a plain object of its exports.
* @private
* @param {*} v - The value to clone.
* @returns {*} The cloned value.
* @param {Record<string, any>} mod - The module namespace returned by import().
* @param {((this: any, key: string, value: any) => any)|undefined} reviver - Reviver function, if any.
* @param {((val: any) => void)|undefined} validate - Validation function, if any.
* @returns {*} The module's value, copied when a reviver or validate is given.
*/
function deepClone(v) {
return typeof globalThis.structuredClone === "function" ? globalThis.structuredClone(v) : JSON.parse(JSON.stringify(v));
function moduleValue(mod, reviver, validate) {
const value = mod.default ?? mod;
if (!reviver && !validate) return value;
const data = value === mod ? { ...mod } : value;
// JSON.parse already returns a fresh value, so a reviver needs no separate clone.
return reviver ? JSON.parse(JSON.stringify(data), reviver) : deepClone(data);
}

/**
* Runs the caller's validation on a loaded value, throwing the wisp-prefixed load error when it rejects the data.
* @private
* @param {((val: any) => void)|undefined} validate - Validation function, if any.
* @param {*} val - The parsed JSON value.
* @param {*} val - The loaded value.
* @param {URL} url - The URL the value was loaded from.
* @returns {void}
*/
Expand Down Expand Up @@ -100,36 +112,20 @@ export async function wisp(input, options = {}) {
else url = new URL(resolveUrlFromCaller(s));
}

try {
const mod = await import(url.href, { with: { type } });
if (!reviver && !validate) return mod?.default ?? mod;
let val = deepClone(mod?.default ?? mod);
if (reviver) val = JSON.parse(JSON.stringify(val), reviver);
if (validate) {
try {
validate(val);
} catch (e) {
throw new Error(`@cldmv/wisp: ${e?.message ?? e}`, { cause: e });
}
}
return val;
} catch {}

try {
// Legacy import assertions (`assert`) for Node 16.14-20.9, which predate `with`; the cast keeps the type checker from rejecting the key.
const mod = await import(url.href, /** @type {any} */ ({ assert: { type } }));
if (!reviver && !validate) return mod?.default ?? mod;
let val = deepClone(mod?.default ?? mod);
if (reviver) val = JSON.parse(JSON.stringify(val), reviver);
if (validate) {
try {
validate(val);
} catch (e) {
throw new Error(`@cldmv/wisp: ${e?.message ?? e}`, { cause: e });
}
// Each import() strategy only has to load the module; validate runs after the strategy loop, so a
// rejection is reported as the validation error instead of being treated as a failed strategy.
// Legacy import assertions (`assert`) serve Node 16.14-20.9, which predate `with`; the cast keeps the type checker from rejecting the key.
const loadWith = async (attributes) => moduleValue(await import(url.href, attributes), reviver, validate);
for (const attributes of [{ with: { type } }, /** @type {any} */ ({ assert: { type } })]) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
let val;
try {
val = await loadWith(attributes);
} catch {
continue;
}
runValidate(validate, val, url);
return val;
} catch {}
}

if (type === "json") {
let val;
Expand Down
82 changes: 60 additions & 22 deletions tests/import-strategies.test.vitest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ describe("import() with `with` attributes", () => {
);
});

it("runs a rejecting validate once instead of once per load strategy", async () => {
let calls = 0;
await expect(
wisp(sample, {
validate: () => {
calls++;
throw new Error("once");
}
})
).rejects.toThrow(/: @cldmv\/wisp: once$/);
expect(calls).toBe(1);
});

it("keeps the validation error as the cause", async () => {
const original = new Error("nope");
const err = await wisp(sample, {
Expand Down Expand Up @@ -169,37 +182,62 @@ describe.runIf(retriesFailedImport)("import() with legacy `assert` attributes",
const first = await wisp(url, { type: "javascript" });
const second = await wisp(url, { type: "javascript" });
expect(second).toBe(first);
// A reviver needs a clone, which a namespace object cannot give, on every strategy.
await expect(wisp(url, { type: "javascript", reviver: (key, value) => value })).rejects.toThrow(
/^@cldmv\/wisp: Unsupported type 'javascript'/
);
// The cached namespace has no default export, so the reviver gets a plain-object copy of it.
const revived = await wisp(url, { type: "javascript", reviver: (key, value) => (key === "other" ? value + 1 : value) });
expect(revived).toEqual({ named: "value", other: 3 });
// A validation failure on the `with` attempt surfaces as the validation error too.
await expect(
wisp(url, {
type: "javascript",
validate: () => {
throw "rejected on with";
}
})
).rejects.toThrow(/^@cldmv\/wisp: Failed to load JSON file at file:.*no-default\.mjs\?case=\d+: @cldmv\/wisp: rejected on with$/);
});

it("cannot clone a namespace without a default export, so a reviver ends in the unsupported-type error", async () => {
// structuredClone rejects a module namespace object; that failure is swallowed like any
// other strategy failure, and the fs.readFile strategy only handles type "json".
await expect(wisp(fresh(noDefaultFile), { type: "javascript", reviver: (key, value) => value })).rejects.toThrow(
/^@cldmv\/wisp: Unsupported type 'javascript' or failed to load module at file:.*no-default\.mjs\?case=\d+$/
);
it("applies a reviver to a plain-object copy of a namespace without a default export", async () => {
const keys = [];
const data = await wisp(fresh(noDefaultFile), {
type: "javascript",
reviver: (key, value) => {
keys.push(key);
return key === "named" ? "revived" : value;
}
});
expect(data).toEqual({ named: "revived", other: 2 });
expect(Object.getPrototypeOf(data)).toBe(Object.prototype);
expect(keys).toEqual(["named", "other", ""]);
});

it("validates a plain-object copy of a namespace without a default export", async () => {
const seen = [];
const data = await wisp(fresh(noDefaultFile), { type: "javascript", validate: (val) => seen.push(val) });
expect(data).toEqual({ named: "value", other: 2 });
expect(Object.getPrototypeOf(data)).toBe(Object.prototype);
expect(seen).toEqual([data]);
});

it("reports a validation failure on a non-JSON module as an unsupported type", async () => {
// The `assert` attempt's validation error is swallowed like any other failure, and the
// fs.readFile strategy only handles type "json", so the final error is the unsupported-type one.
it("reports a validation failure on a non-JSON module as the validation error", async () => {
let calls = 0;
const validate = () => {
calls++;
throw "rejected";
};
await expect(wisp(fresh(moduleFile), { type: "javascript", validate })).rejects.toThrow(
/^@cldmv\/wisp: Unsupported type 'javascript' or failed to load module at file:.*module\.mjs\?case=\d+$/
/^@cldmv\/wisp: Failed to load JSON file at file:.*module\.mjs\?case=\d+: @cldmv\/wisp: rejected$/
);
await expect(
wisp(fresh(moduleFile), {
type: "javascript",
validate: () => {
throw new Error("rejected as Error");
}
})
).rejects.toThrow(/^@cldmv\/wisp: Unsupported type 'javascript'/);
// The rejection is final: no later strategy loads the module again and re-runs validate.
expect(calls).toBe(1);
const original = new Error("rejected as Error");
const err = await wisp(fresh(moduleFile), {
type: "javascript",
validate: () => {
throw original;
}
}).catch((e) => e);
expect(err.message).toMatch(/: @cldmv\/wisp: rejected as Error$/);
expect(err.cause).toBe(original);
});
});

Expand Down
2 changes: 1 addition & 1 deletion types/wisp.d.mts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading