Checkout: forward only the checkout fields, show clear errors, fix the contact form #16
+5
-1
@@ -72,7 +72,11 @@ export default defineNuxtConfig({
|
||||
|
||||
// TypeScript
|
||||
typescript: {
|
||||
strict: true
|
||||
strict: true,
|
||||
// The unit tests run on Node (npm test), not in the app: keep them out of the app's type check (.nuxt/tsconfig.app.json).
|
||||
tsConfig: {
|
||||
exclude: ["../tests/**/*"]
|
||||
}
|
||||
},
|
||||
|
||||
// Tailwind
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "node --experimental-strip-types --import ./tests/unit/support/register.mjs --test 'tests/unit/**/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchCms } from "~/server/utils/cmsApi";
|
||||
import { forwardToCms } from "~/server/utils/cmsApi";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const uuid = getRouterParam(event, "uuid");
|
||||
@@ -6,5 +6,5 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
|
||||
}
|
||||
|
||||
return await fetchCms(`/orders/${uuid}/cart`);
|
||||
return await forwardToCms(`/orders/${uuid}/cart`);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchCms } from "~/server/utils/cmsApi";
|
||||
import { forwardToCms } from "~/server/utils/cmsApi";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const uuid = getRouterParam(event, "uuid");
|
||||
@@ -8,7 +8,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
return await fetchCms(`/orders/${uuid}/cart`, {
|
||||
return await forwardToCms(`/orders/${uuid}/cart`, {
|
||||
method: "PUT",
|
||||
body
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchCms } from "~/server/utils/cmsApi";
|
||||
import { forwardToCms } from "~/server/utils/cmsApi";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const uuid = getRouterParam(event, "uuid");
|
||||
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const count = query.count || 1;
|
||||
|
||||
return await fetchCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, {
|
||||
return await forwardToCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, {
|
||||
method: "PUT"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchCms } from "~/server/utils/cmsApi";
|
||||
import { forwardToCms } from "~/server/utils/cmsApi";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const uuid = getRouterParam(event, "uuid");
|
||||
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, statusMessage: "Missing PayPal order ID" });
|
||||
}
|
||||
|
||||
return await fetchCms(`/orders/${uuid}/capture/${paypalOrderId}`, {
|
||||
return await forwardToCms(`/orders/${uuid}/capture/${paypalOrderId}`, {
|
||||
method: "POST"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchCms } from "~/server/utils/cmsApi";
|
||||
import { forwardToCms } from "~/server/utils/cmsApi";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const uuid = getRouterParam(event, "uuid");
|
||||
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const returnUrl = query.returnUrl || "";
|
||||
|
||||
return await fetchCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, {
|
||||
return await forwardToCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, {
|
||||
method: "POST"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchCms } from "~/server/utils/cmsApi";
|
||||
import { forwardToCms } from "~/server/utils/cmsApi";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const uuid = getRouterParam(event, "uuid");
|
||||
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const count = query.count || 1;
|
||||
|
||||
return await fetchCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, {
|
||||
return await forwardToCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, {
|
||||
method: "PUT"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cmsErrorLogLine, shopErrorFromCms } from "./cmsError";
|
||||
|
||||
/**
|
||||
* Server-side CMS API utility.
|
||||
* Use this for direct CMS access during SSR.
|
||||
@@ -15,3 +17,18 @@ export async function fetchCms<T>(endpoint: string, options: Parameters<typeof $
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* fetchCms for a route that forwards the browser's request to the CMS: a CMS error answers the browser with the CMS's status
|
||||
* and the safe fields of its error (cmsError.ts), instead of an unhandled error, whose message and data Nitro hides.
|
||||
*/
|
||||
export async function forwardToCms<T>(endpoint: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
|
||||
try {
|
||||
return await fetchCms<T>(endpoint, options);
|
||||
} catch (error) {
|
||||
const shopError = shopErrorFromCms(error);
|
||||
if (!shopError) throw error;
|
||||
if (shopError.statusCode >= 500) console.error(cmsErrorLogLine(String(options.method ?? "GET"), endpoint, shopError));
|
||||
throw createError(shopError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// How a failed CMS request answers the browser: with the CMS's status and only the fields of its error that are safe to show.
|
||||
// The CMS (Strapi) answers an error as { data: null, error: { status, name, message, details } }, which $fetch throws as ofetch's
|
||||
// FetchError with the response's status and parsed body. Thrown on as it is, Nitro treats it as unhandled: it hides the message and
|
||||
// the data from the browser and logs the CMS URL. forwardToCms (cmsApi.ts) throws createError(shopErrorFromCms(error)) instead.
|
||||
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/cmsError.test.ts.
|
||||
|
||||
/** What the browser receives as the data of the error. errors and missing are the CMS's details.errors and details.missing. */
|
||||
export type ShopErrorData = { message: string; errors?: string[]; missing?: string[] };
|
||||
|
||||
/** The argument for h3's createError. */
|
||||
export type ShopError = { statusCode: number; statusMessage: string; data: ShopErrorData };
|
||||
|
||||
// The statuses passed on: the CMS's answers about the order and its payment. Any other status, such as 401 or 403 for a wrong
|
||||
// API token, is the shop's own fault and answered 500.
|
||||
const STATUS_TEXTS = new Map<number, string>([
|
||||
[400, "Bad Request"],
|
||||
[404, "Not Found"],
|
||||
[409, "Conflict"],
|
||||
[500, "Internal Server Error"],
|
||||
[502, "Bad Gateway"],
|
||||
[503, "Service Unavailable"],
|
||||
[504, "Gateway Timeout"]
|
||||
]);
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 200;
|
||||
const MAX_STATUS_MESSAGE_LENGTH = 100;
|
||||
const MAX_FIELD_LENGTH = 64;
|
||||
const MAX_LIST_LENGTH = 20;
|
||||
|
||||
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const cut = (text: string, maxLength: number): string => (text.length > maxLength ? `${text.slice(0, maxLength)}…` : text);
|
||||
|
||||
// The strings of a list in the CMS's details, e.g. ["data.email: must be an email address of at most 254 characters"].
|
||||
const stringList = (value: unknown, maxLength: number): string[] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const list = value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.slice(0, MAX_LIST_LENGTH)
|
||||
.map((item) => cut(item, maxLength));
|
||||
return list.length > 0 ? list : undefined;
|
||||
};
|
||||
|
||||
// The status message is the HTTP reason phrase, which allows printable ASCII only.
|
||||
const isReasonPhrase = (text: string): boolean => text.length <= MAX_STATUS_MESSAGE_LENGTH && /^[\x20-\x7e]+$/.test(text);
|
||||
|
||||
/**
|
||||
* The error to throw to the browser for an error of $fetch to the CMS, or undefined if the error is not ofetch's FetchError
|
||||
* (a bug, to rethrow as it is). The CMS's status is kept for 400, 404, 409, 500, 502, 503 and 504, any other is answered 500,
|
||||
* and a CMS that does not answer 503. The data holds the CMS's message, details.errors and details.missing, and nothing else.
|
||||
*/
|
||||
export const shopErrorFromCms = (error: unknown): ShopError | undefined => {
|
||||
if (!(error instanceof Error) || error.name !== "FetchError") return undefined;
|
||||
const { status, data } = error as Error & { status?: unknown; data?: unknown };
|
||||
|
||||
if (typeof status !== "number") {
|
||||
return { statusCode: 503, statusMessage: "Service Unavailable", data: { message: "The CMS did not answer" } };
|
||||
}
|
||||
const statusText = STATUS_TEXTS.get(status);
|
||||
if (statusText === undefined) {
|
||||
return { statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } };
|
||||
}
|
||||
|
||||
const cmsError: Record<string, unknown> = isObject(data) && isObject(data.error) ? data.error : {};
|
||||
const details: Record<string, unknown> = isObject(cmsError.details) ? cmsError.details : {};
|
||||
const message = typeof cmsError.message === "string" && cmsError.message !== "" ? cut(cmsError.message, MAX_MESSAGE_LENGTH) : statusText;
|
||||
const errors = stringList(details.errors, MAX_MESSAGE_LENGTH);
|
||||
const missing = stringList(details.missing, MAX_FIELD_LENGTH);
|
||||
|
||||
return {
|
||||
statusCode: status,
|
||||
statusMessage: isReasonPhrase(message) ? message : statusText,
|
||||
data: { message, ...(errors ? { errors } : {}), ...(missing ? { missing } : {}) }
|
||||
};
|
||||
};
|
||||
|
||||
/** The log line for a failed CMS request: without its query, and with order uuids replaced, since a uuid opens its order. */
|
||||
export const cmsErrorLogLine = (method: string, endpoint: string, error: ShopError): string =>
|
||||
`[cms] ${method.toUpperCase()} ${endpoint.replace(/\?.*$/, "").replace(UUID, ":uuid")}: ${error.statusCode} ${error.data.message}`;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { cmsErrorLogLine, shopErrorFromCms } from "../../server/utils/cmsError.ts";
|
||||
|
||||
const UUID = "11111111-2222-4333-8444-555555555555";
|
||||
const CART = `http://cms:5555/api/orders/${UUID}/cart`;
|
||||
const CHECKOUT = `http://cms:5555/api/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example%2Fcheckout%2F3`;
|
||||
const CAPTURE = `http://cms:5555/api/orders/${UUID}/capture/5O190127TN364715T`;
|
||||
|
||||
// Built like ofetch's createFetchError (ofetch 1.5): an Error named FetchError, with getters for the response's status and parsed body.
|
||||
const fetchError = (method: string, url: string, response?: { status: number; statusText: string; body: unknown }): Error => {
|
||||
const status = response ? `${response.status} ${response.statusText}` : "<no response> fetch failed";
|
||||
const error = new Error(`[${method}] ${JSON.stringify(url)}: ${status}`);
|
||||
error.name = "FetchError";
|
||||
const fields: [string, unknown][] = [
|
||||
["data", response?.body],
|
||||
["status", response?.status],
|
||||
["statusCode", response?.status],
|
||||
["statusText", response?.statusText],
|
||||
["statusMessage", response?.statusText]
|
||||
];
|
||||
for (const [key, value] of fields) Object.defineProperty(error, key, { get: () => value });
|
||||
return error;
|
||||
};
|
||||
|
||||
// Strapi's error body, as ctx.badRequest, ctx.notFound, ctx.conflict and ctx.badGateway write it.
|
||||
const strapiError = (status: number, name: string, message: string, details: Record<string, unknown> = {}) => ({
|
||||
data: null,
|
||||
error: { status, name, message, details }
|
||||
});
|
||||
|
||||
const cmsAnswer = (method: string, url: string, status: number, statusText: string, body: unknown) =>
|
||||
shopErrorFromCms(fetchError(method, url, { status, statusText, body }));
|
||||
|
||||
test("passes a rejected order update on as 400 with the CMS's errors", () => {
|
||||
const errors = ["data.email: must be an email address of at most 254 characters", "data.total: not a field the customer may set"];
|
||||
|
||||
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", strapiError(400, "BadRequestError", "Invalid order update", { errors })), {
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid order update",
|
||||
data: { message: "Invalid order update", errors }
|
||||
});
|
||||
});
|
||||
|
||||
test("passes a checkout that is not ready on as 400 with the fields it misses", () => {
|
||||
const body = strapiError(400, "BadRequestError", "Order is not ready for checkout", { missing: ["email", "delivery"] });
|
||||
|
||||
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
|
||||
statusCode: 400,
|
||||
statusMessage: "Order is not ready for checkout",
|
||||
data: { message: "Order is not ready for checkout", missing: ["email", "delivery"] }
|
||||
});
|
||||
});
|
||||
|
||||
test("passes on no detail but errors and missing", () => {
|
||||
const details = { reason: "deliveryAddress has no postal code", order: { email: "erika@example.org" }, errors: "data.email" };
|
||||
const body = { ...strapiError(400, "BadRequestError", "Order is not ready for checkout", details), meta: { email: "erika@example.org" } };
|
||||
|
||||
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
|
||||
statusCode: 400,
|
||||
statusMessage: "Order is not ready for checkout",
|
||||
data: { message: "Order is not ready for checkout" }
|
||||
});
|
||||
});
|
||||
|
||||
test("passes the conflicts of a paid order, an unavailable product and a payment that does not fit the order on as 409", () => {
|
||||
for (const [url, message] of [
|
||||
[CART, "Order can no longer be changed"],
|
||||
[CHECKOUT, "A product in the cart is no longer available"],
|
||||
[CAPTURE, "Order is already paid"],
|
||||
[CAPTURE, "Payment does not belong to this order"],
|
||||
[CAPTURE, "Payment does not match this order"],
|
||||
[CAPTURE, "Order changed during the payment"]
|
||||
] as const) {
|
||||
assert.deepEqual(
|
||||
cmsAnswer("POST", url, 409, "Conflict", strapiError(409, "ConflictError", message)),
|
||||
{ statusCode: 409, statusMessage: message, data: { message } },
|
||||
message
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("passes a missing order on as 404", () => {
|
||||
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "Order not found")), {
|
||||
statusCode: 404,
|
||||
statusMessage: "Order not found",
|
||||
data: { message: "Order not found" }
|
||||
});
|
||||
});
|
||||
|
||||
test("passes PayPal's errors on as 502", () => {
|
||||
for (const [url, message] of [
|
||||
[CHECKOUT, "Could not create the PayPal order"],
|
||||
[CAPTURE, "PayPal could not capture the payment"],
|
||||
[CAPTURE, "Payment could not be confirmed"]
|
||||
] as const) {
|
||||
assert.deepEqual(
|
||||
cmsAnswer("POST", url, 502, "Bad Gateway", strapiError(502, "BadGatewayError", message)),
|
||||
{ statusCode: 502, statusMessage: message, data: { message } },
|
||||
message
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("passes a payment the CMS could not record on as 500 with its message", () => {
|
||||
const body = strapiError(500, "InternalServerError", "Payment could not be recorded");
|
||||
|
||||
assert.deepEqual(cmsAnswer("POST", CAPTURE, 500, "Internal Server Error", body), {
|
||||
statusCode: 500,
|
||||
statusMessage: "Payment could not be recorded",
|
||||
data: { message: "Payment could not be recorded" }
|
||||
});
|
||||
});
|
||||
|
||||
test("answers 500 without the CMS's message for a status that is the shop's own fault", () => {
|
||||
for (const [status, statusText] of [
|
||||
[401, "Unauthorized"],
|
||||
[403, "Forbidden"],
|
||||
[405, "Method Not Allowed"],
|
||||
[413, "Payload Too Large"]
|
||||
] as const) {
|
||||
assert.deepEqual(
|
||||
cmsAnswer("PUT", CART, status, statusText, strapiError(status, "Error", "Missing or invalid credentials")),
|
||||
{ statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } },
|
||||
String(status)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("answers 503 when the CMS does not answer", () => {
|
||||
assert.deepEqual(shopErrorFromCms(fetchError("GET", CART)), {
|
||||
statusCode: 503,
|
||||
statusMessage: "Service Unavailable",
|
||||
data: { message: "The CMS did not answer" }
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves any other error to be rethrown as it is", () => {
|
||||
assert.equal(shopErrorFromCms(new TypeError("Cannot read properties of undefined (reading 'uuid')")), undefined);
|
||||
assert.equal(shopErrorFromCms({ name: "FetchError", status: 409, data: strapiError(409, "ConflictError", "Order is already paid") }), undefined);
|
||||
assert.equal(shopErrorFromCms("FetchError"), undefined);
|
||||
assert.equal(shopErrorFromCms(undefined), undefined);
|
||||
});
|
||||
|
||||
test("keeps only the strings of the CMS's lists, at most 20, and cuts a long one", () => {
|
||||
// Strapi's own validation errors are objects with the path and the message: they are dropped.
|
||||
const objects = strapiError(400, "ValidationError", "Invalid order update", {
|
||||
errors: [{ path: ["email"], message: "email must be a valid email" }]
|
||||
});
|
||||
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", objects)?.data, { message: "Invalid order update" });
|
||||
|
||||
const many = Array.from({ length: 25 }, (_, index) => `data.field${index}: not a field the customer may set`);
|
||||
const long = `data.${"x".repeat(300)}: not a field the customer may set`;
|
||||
const body = strapiError(400, "BadRequestError", "Invalid order update", { errors: [long, 7, ...many] });
|
||||
const data = cmsAnswer("PUT", CART, 400, "Bad Request", body)?.data;
|
||||
|
||||
assert.equal(data?.errors?.length, 20);
|
||||
assert.equal(data?.errors?.[0], `${long.slice(0, 200)}…`);
|
||||
assert.equal(data?.errors?.[1], many[0]);
|
||||
});
|
||||
|
||||
test("answers with the status's reason phrase when the body is no CMS error", () => {
|
||||
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 502, "Bad Gateway", "<html><body>502 Bad Gateway</body></html>"), {
|
||||
statusCode: 502,
|
||||
statusMessage: "Bad Gateway",
|
||||
data: { message: "Bad Gateway" }
|
||||
});
|
||||
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "")), {
|
||||
statusCode: 404,
|
||||
statusMessage: "Not Found",
|
||||
data: { message: "Not Found" }
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a message the status line cannot carry in the data only", () => {
|
||||
const long = `Order ${"x".repeat(250)}`;
|
||||
|
||||
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", "Bestellung gesperrt…")), {
|
||||
statusCode: 409,
|
||||
statusMessage: "Conflict",
|
||||
data: { message: "Bestellung gesperrt…" }
|
||||
});
|
||||
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", long)), {
|
||||
statusCode: 409,
|
||||
statusMessage: "Conflict",
|
||||
data: { message: `${long.slice(0, 200)}…` }
|
||||
});
|
||||
});
|
||||
|
||||
test("logs a failed request without its query and without the order's uuid", () => {
|
||||
const error = { statusCode: 502, statusMessage: "Bad Gateway", data: { message: "Could not create the PayPal order" } };
|
||||
|
||||
assert.equal(
|
||||
cmsErrorLogLine("post", `/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example`, error),
|
||||
"[cms] POST /orders/:uuid/checkout: 502 Could not create the PayPal order"
|
||||
);
|
||||
assert.equal(
|
||||
cmsErrorLogLine("GET", `/orders/${UUID.toUpperCase()}/cart`, error),
|
||||
"[cms] GET /orders/:uuid/cart: 502 Could not create the PayPal order"
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
// Loaded by `npm test` through `node --import`. Node runs the .ts sources directly (type stripping), but unlike Vite and Nitro it does
|
||||
// not resolve extensionless relative imports such as `import { … } from "./cmsError"` in a server/utils module.
|
||||
// The hook below retries such an import from a .ts file with ".ts" appended. Test files import sources with explicit .ts paths.
|
||||
import { register } from "node:module";
|
||||
|
||||
register("./resolve-ts.mjs", import.meta.url);
|
||||
@@ -0,0 +1,14 @@
|
||||
// Module resolve hook, registered by ./register.mjs: `./cmsError` imported from a .ts file resolves to `./cmsError.ts`.
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
try {
|
||||
return await nextResolve(specifier, context);
|
||||
} catch (error) {
|
||||
const relative = specifier.startsWith("./") || specifier.startsWith("../");
|
||||
if (error?.code !== "ERR_MODULE_NOT_FOUND" || !relative || !context.parentURL?.endsWith(".ts")) throw error;
|
||||
try {
|
||||
return await nextResolve(`${specifier}.ts`, context);
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user