From cc1309d3709b251683a0cda0ced448f8bf9f514e Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Wed, 5 Mar 2025 14:04:25 +0100 Subject: [PATCH] chore(product): Improve product normalization and fix http router with tracing (#11724) **What** - Improve product normalization and prevent over fetching data - Fix HTTP router wrap handler with tracing enabled --- .changeset/heavy-items-own.md | 7 + .../__fixtures__/routers/admin/fail/route.ts | 5 + .../src/http/__tests__/index.spec.ts | 16 ++ .../src/http/__tests__/routes-loader.spec.ts | 24 +++ packages/core/framework/src/http/router.ts | 12 +- packages/medusa/package.json | 1 + .../__fixtures__/mocks/index.ts | 20 ++ .../__fixtures__/routers/admin/fail/route.ts | 6 + .../__fixtures__/routers/middlewares.ts | 15 ++ .../__fixtures__/server/index.ts | 183 ++++++++++++++++ .../instrumentation/__tests__/index.spec.ts | 50 +++++ packages/medusa/src/instrumentation/index.ts | 30 +-- .../product-module-service/products.spec.ts | 10 +- .../src/services/product-module-service.ts | 202 ++++++++++-------- yarn.lock | 1 + 15 files changed, 464 insertions(+), 118 deletions(-) create mode 100644 .changeset/heavy-items-own.md create mode 100644 packages/core/framework/src/http/__fixtures__/routers/admin/fail/route.ts create mode 100644 packages/medusa/src/instrumentation/__fixtures__/mocks/index.ts create mode 100644 packages/medusa/src/instrumentation/__fixtures__/routers/admin/fail/route.ts create mode 100644 packages/medusa/src/instrumentation/__fixtures__/routers/middlewares.ts create mode 100644 packages/medusa/src/instrumentation/__fixtures__/server/index.ts create mode 100644 packages/medusa/src/instrumentation/__tests__/index.spec.ts diff --git a/.changeset/heavy-items-own.md b/.changeset/heavy-items-own.md new file mode 100644 index 0000000000000..0b4509473bc70 --- /dev/null +++ b/.changeset/heavy-items-own.md @@ -0,0 +1,7 @@ +--- +"@medusajs/product": patch +"@medusajs/framework": patch +"@medusajs/medusa": patch +--- + +chore(product): Improve product normalization diff --git a/packages/core/framework/src/http/__fixtures__/routers/admin/fail/route.ts b/packages/core/framework/src/http/__fixtures__/routers/admin/fail/route.ts new file mode 100644 index 0000000000000..b31deb661d06c --- /dev/null +++ b/packages/core/framework/src/http/__fixtures__/routers/admin/fail/route.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express" + +export function GET(req: Request, res: Response) { + throw new Error("Failed") +} diff --git a/packages/core/framework/src/http/__tests__/index.spec.ts b/packages/core/framework/src/http/__tests__/index.spec.ts index f31be74b87880..4a8ea7dc65b43 100644 --- a/packages/core/framework/src/http/__tests__/index.spec.ts +++ b/packages/core/framework/src/http/__tests__/index.spec.ts @@ -36,6 +36,22 @@ describe("RoutesLoader", function () { request = request_ }) + it("should be handled by the error handler when a route handler fails", async function () { + const res = await request("GET", "/admin/fail", { + adminSession: { + jwt: { + userId: "admin_user", + }, + }, + }) + + expect(res.status).toBe(500) + console.log(res) + expect(res.text).toBe( + '{"code":"unknown_error","type":"unknown_error","message":"An unknown error occurred."}' + ) + }) + it("should return a status 200 on GET admin/order/:id", async function () { const res = await request("GET", "/admin/orders/1000", { adminSession: { diff --git a/packages/core/framework/src/http/__tests__/routes-loader.spec.ts b/packages/core/framework/src/http/__tests__/routes-loader.spec.ts index 6e1c7d272c0e2..8ae0f86c9df1c 100644 --- a/packages/core/framework/src/http/__tests__/routes-loader.spec.ts +++ b/packages/core/framework/src/http/__tests__/routes-loader.spec.ts @@ -9,6 +9,18 @@ describe("Routes loader", () => { expect(loader.getRoutes()).toMatchInlineSnapshot(` [ + { + "absolutePath": "${BASE_DIR}/admin/fail/route.ts", + "handler": [Function], + "isRoute": true, + "matcher": "/admin/fail", + "method": "GET", + "optedOutOfAuth": false, + "relativePath": "/admin/fail/route.ts", + "shouldAppendAdminCors": true, + "shouldAppendAuthCors": false, + "shouldAppendStoreCors": false, + }, { "absolutePath": "${BASE_DIR}/admin/orders/[id]/route.ts", "handler": [Function], @@ -205,6 +217,18 @@ describe("Routes loader", () => { expect(loader.getRoutes()).toMatchInlineSnapshot(` [ + { + "absolutePath": "${BASE_DIR}/admin/fail/route.ts", + "handler": [Function], + "isRoute": true, + "matcher": "/admin/fail", + "method": "GET", + "optedOutOfAuth": false, + "relativePath": "/admin/fail/route.ts", + "shouldAppendAdminCors": true, + "shouldAppendAuthCors": false, + "shouldAppendStoreCors": false, + }, { "absolutePath": "${BASE_DIR}/admin/orders/[id]/route.ts", "handler": [Function], diff --git a/packages/core/framework/src/http/router.ts b/packages/core/framework/src/http/router.ts index 310011c8a5700..acb4273b1cd6f 100644 --- a/packages/core/framework/src/http/router.ts +++ b/packages/core/framework/src/http/router.ts @@ -104,25 +104,25 @@ export class ApiLoader { if ("isRoute" in route) { logger.debug(`registering route ${route.method} ${route.matcher}`) const handler = ApiLoader.traceRoute - ? ApiLoader.traceRoute(wrapHandler(route.handler), { + ? ApiLoader.traceRoute(route.handler, { route: route.matcher, method: route.method, }) - : wrapHandler(route.handler) + : route.handler - this.#app[route.method.toLowerCase()](route.matcher, handler) + this.#app[route.method.toLowerCase()](route.matcher, wrapHandler(handler)) return } if (!route.methods) { logger.debug(`registering global middleware for ${route.matcher}`) const handler = ApiLoader.traceMiddleware - ? (ApiLoader.traceMiddleware(wrapHandler(route.handler), { + ? (ApiLoader.traceMiddleware(route.handler, { route: route.matcher, }) as RequestHandler) - : (wrapHandler(route.handler) as RequestHandler) + : (route.handler as RequestHandler) - this.#app.use(route.matcher, handler) + this.#app.use(route.matcher, wrapHandler(handler)) return } diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 524e7ac31825d..1b45a3d27bc57 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -60,6 +60,7 @@ "@types/multer": "^1.4.7", "jest": "^29.7.0", "rimraf": "^5.0.1", + "supertest": "^4.0.2", "typescript": "^5.6.2", "yalc": "1.0.0-pre.53" }, diff --git a/packages/medusa/src/instrumentation/__fixtures__/mocks/index.ts b/packages/medusa/src/instrumentation/__fixtures__/mocks/index.ts new file mode 100644 index 0000000000000..0d615a768265c --- /dev/null +++ b/packages/medusa/src/instrumentation/__fixtures__/mocks/index.ts @@ -0,0 +1,20 @@ +import { ConfigModule } from "@medusajs/types" + +export const customersGlobalMiddlewareMock = jest.fn() +export const customersCreateMiddlewareMock = jest.fn() +export const storeGlobalMiddlewareMock = jest.fn() + +export const config = { + projectConfig: { + databaseLogging: false, + http: { + authCors: "http://localhost:9000", + storeCors: "http://localhost:8000", + adminCors: "http://localhost:7001", + jwtSecret: "supersecret", + cookieSecret: "superSecret", + }, + }, + featureFlags: {}, + plugins: [], +} satisfies Partial diff --git a/packages/medusa/src/instrumentation/__fixtures__/routers/admin/fail/route.ts b/packages/medusa/src/instrumentation/__fixtures__/routers/admin/fail/route.ts new file mode 100644 index 0000000000000..7b33e1ee283c5 --- /dev/null +++ b/packages/medusa/src/instrumentation/__fixtures__/routers/admin/fail/route.ts @@ -0,0 +1,6 @@ +import { MedusaError } from "@medusajs/framework/utils" +import { Request, Response } from "express" + +export function GET(req: Request, res: Response) { + throw new MedusaError(MedusaError.Types.INVALID_DATA, "Failed") +} diff --git a/packages/medusa/src/instrumentation/__fixtures__/routers/middlewares.ts b/packages/medusa/src/instrumentation/__fixtures__/routers/middlewares.ts new file mode 100644 index 0000000000000..1639634aca4e2 --- /dev/null +++ b/packages/medusa/src/instrumentation/__fixtures__/routers/middlewares.ts @@ -0,0 +1,15 @@ +import { defineMiddlewares } from "@medusajs/framework" + +export const errorHandlerMock = jest + .fn() + .mockImplementation((err, req, res, next) => { + console.log("errorHandlerMock", err) + return res.status(400).json({ + type: err.code.toLowerCase(), + message: err.message, + }) + }) + +export default defineMiddlewares({ + errorHandler: (err, req, res, next) => errorHandlerMock(err, req, res, next), +}) diff --git a/packages/medusa/src/instrumentation/__fixtures__/server/index.ts b/packages/medusa/src/instrumentation/__fixtures__/server/index.ts new file mode 100644 index 0000000000000..eaec6b43d6b40 --- /dev/null +++ b/packages/medusa/src/instrumentation/__fixtures__/server/index.ts @@ -0,0 +1,183 @@ +import { + moduleLoader, + ModulesDefinition, + registerMedusaModule, +} from "@medusajs/modules-sdk" +import { ContainerRegistrationKeys, generateJwtToken } from "@medusajs/utils" +import { asValue } from "awilix" +import express from "express" +import querystring from "querystring" +import supertest from "supertest" + +import { config } from "../mocks" +import { ConfigModule, MedusaContainer } from "@medusajs/types" +import { configManager } from "@medusajs/framework/config" +import { + ApiLoader, + container, + featureFlagsLoader, + logger, + MedusaRequest, +} from "@medusajs/framework" + +function asArray(resolvers) { + return { + resolve: (container) => + resolvers.map((resolver) => container.build(resolver)), + } +} + +/** + * Sets up a test server that injects API Routes using the RoutesLoader + * + * @param {String} rootDir - The root directory of the project + */ +export const createServer = async (rootDir) => { + const app = express() + + const moduleResolutions = {} + Object.entries(ModulesDefinition).forEach(([moduleKey, module]) => { + moduleResolutions[moduleKey] = registerMedusaModule( + moduleKey, + module.defaultModuleDeclaration, + undefined, + module + )[moduleKey] + }) + + configManager.loadConfig({ + projectConfig: config as unknown as ConfigModule, + baseDir: rootDir, + }) + + container.registerAdd = function (this: MedusaContainer, name, registration) { + const storeKey = name + "_STORE" + + if (this.registrations[storeKey] === undefined) { + this.register(storeKey, asValue([])) + } + const store = this.resolve(storeKey) as Array + + if (this.registrations[name] === undefined) { + this.register(name, asArray(store)) + } + store.unshift(registration) + + return this + }.bind(container) + + container.register(ContainerRegistrationKeys.PG_CONNECTION, asValue({})) + container.register("configModule", asValue(config)) + container.register({ + logger: asValue({ + error: () => {}, + }), + manager: asValue({}), + }) + + app.set("trust proxy", 1) + app.use((req, _res, next) => { + req["session"] = {} + const data = req.get("Cookie") + if (data) { + req["session"] = { + ...req["session"], + ...JSON.parse(data), + } + } + next() + }) + + await featureFlagsLoader() + await moduleLoader({ container, moduleResolutions, logger }) + + app.use((req, res, next) => { + ;(req as MedusaRequest).scope = container.createScope() as MedusaContainer + next() + }) + + await new ApiLoader({ + app, + sourceDir: rootDir, + }).load() + + const superRequest = supertest(app) + + return { + request: async (method, url, opts: any = {}) => { + const { payload, query, headers = {} } = opts + + const queryParams = query && querystring.stringify(query) + const req = superRequest[method.toLowerCase()]( + `${url}${queryParams ? "?" + queryParams : ""}` + ) + headers.Cookie = headers.Cookie || "" + if (opts.adminSession) { + const token = generateJwtToken( + { + actor_id: opts.adminSession.userId || opts.adminSession.jwt?.userId, + actor_type: "user", + app_metadata: { + user_id: + opts.adminSession.userId || opts.adminSession.jwt?.userId, + }, + }, + { + secret: config.projectConfig.http.jwtSecret!, + expiresIn: "1d", + } + ) + + headers.Authorization = `Bearer ${token}` + } + + if (opts.clientSession) { + const token = generateJwtToken( + { + actor_id: + opts.clientSession.customer_id || + opts.clientSession.jwt?.customer_id, + actor_type: "customer", + app_metadata: { + customer_id: + opts.clientSession.customer_id || + opts.clientSession.jwt?.customer_id, + }, + }, + { secret: config.projectConfig.http.jwtSecret!, expiresIn: "1d" } + ) + + headers.Authorization = `Bearer ${token}` + } + + for (const name in headers) { + if ({}.hasOwnProperty.call(headers, name)) { + req.set(name, headers[name]) + } + } + + if (payload && !req.get("content-type")) { + req.set("Content-Type", "application/json") + } + + if (!req.get("accept")) { + req.set("Accept", "application/json") + } + + req.set("Host", "localhost") + + let res + try { + res = await req.send(JSON.stringify(payload)) + } catch (e) { + if (e.response) { + res = e.response + } else { + throw e + } + } + + return res + }, + } +} diff --git a/packages/medusa/src/instrumentation/__tests__/index.spec.ts b/packages/medusa/src/instrumentation/__tests__/index.spec.ts new file mode 100644 index 0000000000000..f88c83c68ae93 --- /dev/null +++ b/packages/medusa/src/instrumentation/__tests__/index.spec.ts @@ -0,0 +1,50 @@ +import { resolve } from "path" +import { errorHandlerMock } from "../__fixtures__/routers/middlewares" +import { createServer } from "../__fixtures__/server" +import { instrumentHttpLayer } from "../index" +import { MedusaError } from "@medusajs/framework/utils" + +jest.setTimeout(30000) + +jest.mock("../../commands/start", () => { + return {} +}) + +describe("HTTP Instrumentation", () => { + let request + + afterEach(function () { + jest.clearAllMocks() + }) + + beforeAll(async function () { + instrumentHttpLayer() + + const rootDir = resolve(__dirname, "../__fixtures__/routers") + + const { request: request_ } = await createServer(rootDir) + + request = request_ + }) + + describe("traceRoute", () => { + it("should be handled by the error handler when a route fails", async () => { + const res = await request("GET", "/admin/fail", { + adminSession: { + jwt: { + userId: "admin_user", + }, + }, + }) + + expect(res.status).toBe(400) + expect(errorHandlerMock).toHaveBeenCalled() + expect(errorHandlerMock).toHaveBeenCalledWith( + new MedusaError(MedusaError.Types.INVALID_DATA, "Failed"), + expect.anything(), + expect.anything(), + expect.anything() + ) + }) + }) +}) diff --git a/packages/medusa/src/instrumentation/index.ts b/packages/medusa/src/instrumentation/index.ts index 46cb40f9675f0..3038f98affe5d 100644 --- a/packages/medusa/src/instrumentation/index.ts +++ b/packages/medusa/src/instrumentation/index.ts @@ -110,26 +110,18 @@ export function instrumentHttpLayer() { }` await HTTPTracer.trace(traceName, async (span) => { - return new Promise((resolve, reject) => { - const _next = (error?: any) => { - if (error) { - span.setStatus({ - code: SpanStatusCode.ERROR, - message: error.message || "Failed", - }) - span.end() - reject(error) - } else { - span.end() - resolve() - } - } - - handler(req, res, _next) - }) + try { + await handler(req, res, next) + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message || "Failed", + }) + throw error + } finally { + span.end() + } }) - .catch(next) - .then(next) } } } diff --git a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts index 22fe12f8bdfb3..e03b77b078299 100644 --- a/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts +++ b/packages/modules/product/integration-tests/__tests__/product-module-service/products.spec.ts @@ -723,10 +723,8 @@ moduleIntegrationTestRunner({ }) it("should throw because variant doesn't have all options set", async () => { - let error - - try { - await service.createProducts([ + const error = await service + .createProducts([ { title: "Product with variants and options", options: [ @@ -741,9 +739,7 @@ moduleIntegrationTestRunner({ ], }, ]) - } catch (e) { - error = e - } + .catch((e) => e) expect(error.message).toEqual( `Product "Product with variants and options" has variants with missing options: [missing option]` diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index e23ab8340267c..211ad631312b7 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -1558,18 +1558,16 @@ export default class ProductModuleService data: ProductTypes.CreateProductDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { - const normalizedInput = await promiseAll( - data.map(async (d) => { - const normalized = await this.normalizeCreateProductInput( - d, - sharedContext - ) - this.validateProductCreatePayload(normalized) - return normalized - }) + const normalizedProducts = await this.normalizeCreateProductInput( + data, + sharedContext ) - const tagIds = normalizedInput + for (const product of normalizedProducts) { + this.validateProductCreatePayload(product) + } + + const tagIds = normalizedProducts .flatMap((d) => (d as any).tags ?? []) .map((t) => t.id) let existingTags: InferEntityType[] = [] @@ -1586,7 +1584,7 @@ export default class ProductModuleService const existingTagsMap = new Map(existingTags.map((tag) => [tag.id, tag])) - const productsToCreate = normalizedInput.map((product) => { + const productsToCreate = normalizedProducts.map((product) => { const productId = generateEntityId(product.id, "prod") product.id = productId @@ -1652,20 +1650,18 @@ export default class ProductModuleService data: UpdateProductInput[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { - const normalizedInput = await promiseAll( - data.map(async (d) => { - const normalized = await this.normalizeUpdateProductInput( - d, - sharedContext - ) - this.validateProductUpdatePayload(normalized) - return normalized - }) + const normalizedProducts = await this.normalizeUpdateProductInput( + data, + sharedContext ) + for (const product of normalizedProducts) { + this.validateProductUpdatePayload(product) + } + const { entities: productData } = await this.productService_.upsertWithReplace( - normalizedInput, + normalizedProducts, { relations: ["tags", "categories"], }, @@ -1675,7 +1671,7 @@ export default class ProductModuleService // There is more than 1-level depth of relations here, so we need to handle the options and variants manually await promiseAll( // Note: It's safe to rely on the order here as `upsertWithReplace` preserves the order of the input - normalizedInput.map(async (product, i) => { + normalizedProducts.map(async (product, i) => { const upsertedProduct: any = productData[i] let allOptions: any[] = [] @@ -1903,91 +1899,125 @@ export default class ProductModuleService this.validateProductPayload(productData) } - protected async normalizeCreateProductInput( - product: ProductTypes.CreateProductDTO, + protected async normalizeCreateProductInput< + T extends ProductTypes.CreateProductDTO | ProductTypes.CreateProductDTO[], + TOutput = T extends ProductTypes.CreateProductDTO[] + ? ProductTypes.CreateProductDTO[] + : ProductTypes.CreateProductDTO + >( + products: T, @MedusaContext() sharedContext: Context = {} - ): Promise { - const productData = (await this.normalizeUpdateProductInput( - product as UpdateProductInput, + ): Promise { + const products_ = Array.isArray(products) ? products : [products] + + const normalizedProducts = (await this.normalizeUpdateProductInput( + products_ as UpdateProductInput[], sharedContext - )) as ProductTypes.CreateProductDTO + )) as ProductTypes.CreateProductDTO[] - if (!productData.handle && productData.title) { - productData.handle = toHandle(productData.title) - } + for (const productData of normalizedProducts) { + if (!productData.handle && productData.title) { + productData.handle = toHandle(productData.title) + } - if (!productData.status) { - productData.status = ProductStatus.DRAFT - } + if (!productData.status) { + productData.status = ProductStatus.DRAFT + } - if (!productData.thumbnail && productData.images?.length) { - productData.thumbnail = productData.images[0].url - } + if (!productData.thumbnail && productData.images?.length) { + productData.thumbnail = productData.images[0].url + } - if (productData.images?.length) { - productData.images = productData.images.map((image, index) => - (image as { rank?: number }).rank != null - ? image - : { - ...image, - rank: index, - } - ) + if (productData.images?.length) { + productData.images = productData.images.map((image, index) => + (image as { rank?: number }).rank != null + ? image + : { + ...image, + rank: index, + } + ) + } } - return productData + return ( + Array.isArray(products) ? normalizedProducts : normalizedProducts[0] + ) as TOutput } - protected async normalizeUpdateProductInput( - product: UpdateProductInput, + protected async normalizeUpdateProductInput< + T extends UpdateProductInput | UpdateProductInput[], + TOutput = T extends UpdateProductInput[] + ? UpdateProductInput[] + : UpdateProductInput + >( + products: T, @MedusaContext() sharedContext: Context = {} - ): Promise { - const productData = { ...product } - if (productData.is_giftcard) { - productData.discountable = false - } + ): Promise { + const products_ = Array.isArray(products) ? products : [products] + const productsIds = products_.map((p) => p.id).filter(Boolean) + + let dbOptions: InferEntityType[] = [] - if (productData.options?.length) { - // TODO: Instead of fetching per product, this should fetch for all product allowing for only one query instead of X - const dbOptions = await this.productOptionService_.list( - { product_id: productData.id }, + if (productsIds.length) { + dbOptions = await this.productOptionService_.list( + { product_id: productsIds }, { relations: ["values"] }, sharedContext ) - - ;(productData as any).options = productData.options?.map((option) => { - const dbOption = dbOptions.find((o) => o.title === option.title) - return { - title: option.title, - values: option.values?.map((value) => { - const dbValue = dbOption?.values?.find((val) => val.value === value) - return { - value: value, - ...(dbValue ? { id: dbValue.id } : {}), - } - }), - ...(dbOption ? { id: dbOption.id } : {}), - } - }) } - if (productData.tag_ids) { - ;(productData as any).tags = productData.tag_ids.map((cid) => ({ - id: cid, - })) - delete productData.tag_ids - } + const normalizedProducts: UpdateProductInput[] = [] - if (productData.category_ids) { - ;(productData as any).categories = productData.category_ids.map( - (cid) => ({ - id: cid, + for (const product of products_) { + const productData = { ...product } + if (productData.is_giftcard) { + productData.discountable = false + } + + if (productData.options?.length) { + ;(productData as any).options = productData.options?.map((option) => { + const dbOption = dbOptions.find( + (o) => o.title === option.title && o.product_id === productData.id + ) + return { + title: option.title, + values: option.values?.map((value) => { + const dbValue = dbOption?.values?.find( + (val) => val.value === value + ) + return { + value: value, + ...(dbValue ? { id: dbValue.id } : {}), + } + }), + ...(dbOption ? { id: dbOption.id } : {}), + } }) - ) - delete productData.category_ids + } + + if (productData.tag_ids) { + ;(productData as any).tags = productData.tag_ids.map((cid) => ({ + id: cid, + })) + delete productData.tag_ids + } + + if (productData.category_ids) { + ;(productData as any).categories = productData.category_ids.map( + (cid) => ({ + id: cid, + }) + ) + delete productData.category_ids + } + + normalizedProducts.push(productData) } - return productData + return ( + Array.isArray(products) ? normalizedProducts : normalizedProducts[0] + ) as TOutput } protected static normalizeCreateProductCollectionInput( diff --git a/yarn.lock b/yarn.lock index f0cea560a8181..093603bee7610 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6155,6 +6155,7 @@ __metadata: request-ip: ^3.3.0 rimraf: ^5.0.1 slugify: ^1.6.6 + supertest: ^4.0.2 typescript: ^5.6.2 uuid: ^9.0.0 yalc: 1.0.0-pre.53