@decocms/apps-shopify 8.0.0 → 8.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +7 -24
  2. package/src/client.ts +113 -46
  3. package/src/{utils/graphqlOperationName.ts → graphqlOperationName.ts} +22 -22
  4. package/src/index.ts +8 -41
  5. package/src/actions/cart/addItems.ts +0 -36
  6. package/src/actions/cart/updateCoupons.ts +0 -31
  7. package/src/actions/cart/updateItems.ts +0 -31
  8. package/src/actions/user/signIn.ts +0 -40
  9. package/src/actions/user/signUp.ts +0 -31
  10. package/src/init.ts +0 -39
  11. package/src/loaders/ProductDetailsPage.ts +0 -31
  12. package/src/loaders/ProductList.ts +0 -102
  13. package/src/loaders/ProductListingPage.ts +0 -172
  14. package/src/loaders/RelatedProducts.ts +0 -42
  15. package/src/loaders/cart.ts +0 -74
  16. package/src/loaders/shop.ts +0 -35
  17. package/src/loaders/user.ts +0 -39
  18. package/src/manifest.gen.ts +0 -39
  19. package/src/mod.ts +0 -67
  20. package/src/registry.ts +0 -9
  21. package/src/utils/__tests__/graphqlOperationName.test.ts +0 -80
  22. package/src/utils/__tests__/instrumentedFetch.test.ts +0 -84
  23. package/src/utils/__tests__/operationRouter.test.ts +0 -56
  24. package/src/utils/__tests__/transform.test.ts +0 -216
  25. package/src/utils/admin/admin.ts +0 -57
  26. package/src/utils/admin/queries.ts +0 -29
  27. package/src/utils/cart.ts +0 -32
  28. package/src/utils/cookies.ts +0 -82
  29. package/src/utils/enums.ts +0 -438
  30. package/src/utils/graphql.ts +0 -69
  31. package/src/utils/instrumentedFetch.ts +0 -55
  32. package/src/utils/operationRouter.ts +0 -70
  33. package/src/utils/storefront/queries.ts +0 -525
  34. package/src/utils/storefront/storefront.graphql.gen.ts +0 -129
  35. package/src/utils/transform.ts +0 -430
  36. package/src/utils/types.ts +0 -191
  37. package/src/utils/user.ts +0 -23
  38. package/src/utils/utils.ts +0 -159
  39. package/tsconfig.json +0 -7
@@ -1,84 +0,0 @@
1
- /**
2
- * Smoke tests for the pre-wired Shopify fetch factory. Same wiring
3
- * assertions as `vtex/utils/__tests__/instrumentedFetch.test.ts`.
4
- */
5
-
6
- import { configureMeter, type MeterAdapter } from "@decocms/blocks/sdk/observability";
7
- import { afterEach, describe, expect, it, vi } from "vitest";
8
- import { createShopifyFetch } from "../instrumentedFetch";
9
-
10
- type Labels = Record<string, string | number | boolean>;
11
-
12
- function captureHistogram(): {
13
- calls: { name: string; value: number; attrs: Labels }[];
14
- meter: MeterAdapter;
15
- } {
16
- const calls: { name: string; value: number; attrs: Labels }[] = [];
17
- const meter: MeterAdapter = {
18
- counterInc: vi.fn(),
19
- gaugeSet: vi.fn(),
20
- histogramRecord: (name, value, attrs) => {
21
- calls.push({ name, value, attrs: attrs ?? {} });
22
- },
23
- };
24
- return { calls, meter };
25
- }
26
-
27
- describe("createShopifyFetch", () => {
28
- afterEach(() => {
29
- vi.restoreAllMocks();
30
- configureMeter({
31
- counterInc: () => {},
32
- gaugeSet: () => {},
33
- histogramRecord: () => {},
34
- });
35
- });
36
-
37
- it("emits http.client.request.duration with provider=shopify on success", async () => {
38
- const { calls, meter } = captureHistogram();
39
- configureMeter(meter);
40
-
41
- const baseFetch = vi.fn(async () => new Response("{}", { status: 200 }));
42
- const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch });
43
-
44
- await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" });
45
-
46
- expect(calls).toHaveLength(1);
47
- expect(calls[0].name).toBe("http.client.request.duration");
48
- expect(calls[0].attrs).toMatchObject({
49
- provider: "shopify",
50
- operation: "storefront.graphql",
51
- status_class: "2xx",
52
- });
53
- });
54
-
55
- it("honors init.operation (used by the GraphQL client to stamp <OperationName>)", async () => {
56
- const { calls, meter } = captureHistogram();
57
- configureMeter(meter);
58
-
59
- const baseFetch = vi.fn(async () => new Response("{}", { status: 200 }));
60
- const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch });
61
-
62
- await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", {
63
- method: "POST",
64
- operation: "ProductBySlug",
65
- });
66
-
67
- expect(calls[0].attrs.operation).toBe("ProductBySlug");
68
- });
69
-
70
- it("skips histogram emission when disableHistogram is true", async () => {
71
- const { calls, meter } = captureHistogram();
72
- configureMeter(meter);
73
-
74
- const baseFetch = vi.fn(async () => new Response("{}", { status: 200 }));
75
- const fetchFn = createShopifyFetch({
76
- baseFetch: baseFetch as typeof fetch,
77
- disableHistogram: true,
78
- });
79
-
80
- await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" });
81
-
82
- expect(calls).toHaveLength(0);
83
- });
84
- });
@@ -1,56 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { shopifyOperationRouter } from "../operationRouter";
3
-
4
- describe("shopifyOperationRouter", () => {
5
- const store = "https://acme.myshopify.com";
6
-
7
- it("recognizes the storefront GraphQL endpoint", () => {
8
- expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "POST")).toBe(
9
- "storefront.graphql",
10
- );
11
- });
12
-
13
- it("recognizes the admin GraphQL endpoint", () => {
14
- expect(shopifyOperationRouter(`${store}/admin/api/2025-04/graphql.json`, "POST")).toBe(
15
- "admin.graphql",
16
- );
17
- });
18
-
19
- it("maps admin REST product / order / customer / inventory endpoints", () => {
20
- expect(shopifyOperationRouter(`${store}/admin/api/2025-04/products.json`, "GET")).toBe(
21
- "admin.products",
22
- );
23
- expect(shopifyOperationRouter(`${store}/admin/api/2025-04/orders/123.json`, "GET")).toBe(
24
- "admin.orders",
25
- );
26
- expect(shopifyOperationRouter(`${store}/admin/api/2025-04/customers.json`, "GET")).toBe(
27
- "admin.customers",
28
- );
29
- expect(shopifyOperationRouter(`${store}/admin/api/2025-04/inventory_levels.json`, "GET")).toBe(
30
- "admin.inventory",
31
- );
32
- });
33
-
34
- it("maps storefront checkout + cart endpoints", () => {
35
- expect(shopifyOperationRouter(`${store}/api/2025-04/checkouts/abc.json`, "POST")).toBe(
36
- "storefront.checkout",
37
- );
38
- expect(shopifyOperationRouter(`${store}/cart.js`, "GET")).toBe("storefront.cart");
39
- expect(shopifyOperationRouter(`${store}/cart/add.js`, "POST")).toBe("storefront.cart");
40
- });
41
-
42
- it("returns undefined for unrecognized paths", () => {
43
- expect(shopifyOperationRouter(`${store}/random/path`, "GET")).toBeUndefined();
44
- });
45
-
46
- it("does not throw on unparseable URLs", () => {
47
- expect(shopifyOperationRouter("not-a-url", "GET")).toBeUndefined();
48
- expect(shopifyOperationRouter("/api/2025-04/graphql.json", "POST")).toBe("storefront.graphql");
49
- });
50
-
51
- it("is case-insensitive on method (no behavioral impact today; future-proofing)", () => {
52
- expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "post")).toBe(
53
- "storefront.graphql",
54
- );
55
- });
56
- });
@@ -1,216 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { ProductShopify, SkuShopify } from "../transform";
3
- import {
4
- toBreadcrumbItem,
5
- toBreadcrumbList,
6
- toFilter,
7
- toProduct,
8
- toProductPage,
9
- } from "../transform";
10
-
11
- const makeSku = (overrides?: Partial<SkuShopify>): SkuShopify => ({
12
- id: "gid://shopify/ProductVariant/12345",
13
- title: "Default / Small",
14
- availableForSale: true,
15
- quantityAvailable: 10,
16
- barcode: "123456789",
17
- sku: "SKU-001",
18
- image: { url: "https://cdn.shopify.com/img.jpg", altText: "Product image" },
19
- price: { amount: "99.90", currencyCode: "BRL" },
20
- compareAtPrice: { amount: "129.90", currencyCode: "BRL" },
21
- selectedOptions: [{ name: "Size", value: "Small" }],
22
- ...overrides,
23
- });
24
-
25
- const makeProduct = (overrides?: Partial<ProductShopify>): ProductShopify => ({
26
- id: "gid://shopify/Product/1",
27
- handle: "test-product",
28
- title: "Test Product",
29
- description: "A test product",
30
- descriptionHtml: "<p>A test product</p>",
31
- createdAt: "2024-01-01",
32
- tags: ["sale", "new"],
33
- vendor: "TestBrand",
34
- productType: "Clothing",
35
- seo: { title: "Test Product SEO", description: "SEO description" },
36
- images: { nodes: [{ url: "https://cdn.shopify.com/img.jpg", altText: "Product" }] },
37
- media: { nodes: [] },
38
- variants: { nodes: [makeSku()] },
39
- collections: {
40
- nodes: [{ handle: "clothing", title: "Clothing", id: "col1" }],
41
- },
42
- ...overrides,
43
- });
44
-
45
- describe("toBreadcrumbItem", () => {
46
- it("creates breadcrumb item", () => {
47
- const result = toBreadcrumbItem({ name: "Home", position: 1, item: "/" });
48
- expect(result).toEqual({
49
- "@type": "ListItem",
50
- name: "Home",
51
- position: 1,
52
- item: "/",
53
- });
54
- });
55
-
56
- it("decodes URI-encoded names", () => {
57
- const result = toBreadcrumbItem({ name: "Cal%C3%A7ados", position: 1, item: "/calcados" });
58
- expect(result.name).toBe("Calçados");
59
- });
60
- });
61
-
62
- describe("toBreadcrumbList", () => {
63
- it("includes collection in breadcrumb when present", () => {
64
- const product = makeProduct();
65
- const sku = makeSku();
66
- const result = toBreadcrumbList(product, sku);
67
- expect(result.itemListElement).toHaveLength(2);
68
- expect(result.itemListElement[0].name).toBe("Clothing");
69
- expect(result.itemListElement[1].name).toBe("Test Product");
70
- expect(result.numberOfItems).toBe(2);
71
- });
72
-
73
- it("omits collection when not present", () => {
74
- const product = makeProduct({ collections: undefined });
75
- const sku = makeSku();
76
- const result = toBreadcrumbList(product, sku);
77
- expect(result.itemListElement).toHaveLength(1);
78
- expect(result.itemListElement[0].name).toBe("Test Product");
79
- });
80
- });
81
-
82
- describe("toProduct", () => {
83
- it("transforms Shopify product to schema.org Product", () => {
84
- const product = makeProduct();
85
- const sku = makeSku();
86
- const url = new URL("https://example.com/products/test-product-12345");
87
- const result = toProduct(product, sku, url);
88
-
89
- expect(result["@type"]).toBe("Product");
90
- expect(result.productID).toBe("gid://shopify/ProductVariant/12345");
91
- expect(result.name).toBe("Default / Small");
92
- expect(result.description).toBe("A test product");
93
- expect(result.brand).toEqual({ "@type": "Brand", name: "TestBrand" });
94
- expect(result.offers?.priceCurrency).toBe("BRL");
95
- expect(result.offers?.lowPrice).toBe(99.9);
96
- expect(result.offers?.highPrice).toBe(129.9);
97
- });
98
-
99
- it("sets availability based on availableForSale", () => {
100
- const product = makeProduct();
101
- const sku = makeSku({ availableForSale: false });
102
- const url = new URL("https://example.com");
103
- const result = toProduct(product, sku, url);
104
- expect(result.offers?.offers[0]?.availability).toBe("https://schema.org/OutOfStock");
105
- });
106
-
107
- it("uses sale price as high price when no compare at price", () => {
108
- const product = makeProduct();
109
- const sku = makeSku({ compareAtPrice: null });
110
- const url = new URL("https://example.com");
111
- const result = toProduct(product, sku, url);
112
- expect(result.offers?.highPrice).toBe(99.9);
113
- });
114
-
115
- it("includes variants at level 0", () => {
116
- const product = makeProduct();
117
- const sku = makeSku();
118
- const url = new URL("https://example.com");
119
- const result = toProduct(product, sku, url, 0);
120
- expect(result.isVariantOf?.hasVariant).toHaveLength(1);
121
- });
122
-
123
- it("does not include variants at level 1", () => {
124
- const product = makeProduct();
125
- const sku = makeSku();
126
- const url = new URL("https://example.com");
127
- const result = toProduct(product, sku, url, 1);
128
- expect(result.isVariantOf?.hasVariant).toEqual([]);
129
- });
130
- });
131
-
132
- describe("toProductPage", () => {
133
- it("creates a full ProductDetailsPage", () => {
134
- const product = makeProduct();
135
- const url = new URL("https://example.com/products/test-product-12345");
136
- const result = toProductPage(product, url);
137
-
138
- expect(result["@type"]).toBe("ProductDetailsPage");
139
- expect(result.breadcrumbList["@type"]).toBe("BreadcrumbList");
140
- expect(result.product["@type"]).toBe("Product");
141
- expect(result.seo?.title).toBe("Test Product SEO");
142
- expect(result.seo?.description).toBe("SEO description");
143
- });
144
-
145
- it("falls back to product title/description for SEO", () => {
146
- const product = makeProduct({ seo: { title: null, description: null } });
147
- const url = new URL("https://example.com");
148
- const result = toProductPage(product, url);
149
- expect(result.seo?.title).toBe("Test Product");
150
- expect(result.seo?.description).toBe("A test product");
151
- });
152
-
153
- it("selects specified variant by ID", () => {
154
- const sku1 = makeSku({ id: "gid://shopify/ProductVariant/111", title: "Red" });
155
- const sku2 = makeSku({ id: "gid://shopify/ProductVariant/222", title: "Blue" });
156
- const product = makeProduct({ variants: { nodes: [sku1, sku2] } });
157
- const url = new URL("https://example.com");
158
- const result = toProductPage(product, url, 222);
159
- expect(result.product.name).toBe("Blue");
160
- });
161
- });
162
-
163
- describe("toFilter", () => {
164
- it("creates toggle filter", () => {
165
- const filter = {
166
- id: "filter.v.color",
167
- label: "Color",
168
- type: "LIST",
169
- values: [
170
- { id: "1", label: "Red", count: 5, input: '{"productVendor":"Red"}' },
171
- { id: "2", label: "Blue", count: 3, input: '{"productVendor":"Blue"}' },
172
- ],
173
- };
174
- const url = new URL("https://example.com/collections/all");
175
- const result = toFilter(filter, url);
176
-
177
- expect(result["@type"]).toBe("FilterToggle");
178
- expect(result.label).toBe("Color");
179
- if (result["@type"] === "FilterToggle") {
180
- expect(result.values).toHaveLength(2);
181
- expect(result.values[0].label).toBe("Red");
182
- expect(result.values[0].selected).toBe(false);
183
- }
184
- });
185
-
186
- it("creates range filter for PRICE_RANGE type", () => {
187
- const filter = {
188
- id: "filter.v.price",
189
- label: "Price",
190
- type: "PRICE_RANGE",
191
- values: [{ id: "1", label: "0-100", count: 10, input: '{"min":0,"max":100}' }],
192
- };
193
- const url = new URL("https://example.com");
194
- const result = toFilter(filter, url);
195
-
196
- expect(result["@type"]).toBe("FilterRange");
197
- if (result["@type"] === "FilterRange") {
198
- expect(result.values.min).toBe(0);
199
- expect(result.values.max).toBe(100);
200
- }
201
- });
202
-
203
- it("marks filter as selected when URL contains it", () => {
204
- const filter = {
205
- id: "filter.v.vendor",
206
- label: "Brand",
207
- type: "LIST",
208
- values: [{ id: "1", label: "Nike", count: 5, input: '{"productVendor":"Nike"}' }],
209
- };
210
- const url = new URL("https://example.com?filter.v.vendor=Nike");
211
- const result = toFilter(filter, url);
212
- if (result["@type"] === "FilterToggle") {
213
- expect(result.values[0].selected).toBe(true);
214
- }
215
- });
216
- });
@@ -1,57 +0,0 @@
1
- // deno-fmt-ignore-file
2
- // deno-lint-ignore-file no-explicit-any ban-types ban-unused-ignore
3
- // biome-ignore-all lint/suspicious/noExplicitAny: generated GraphQL scalar types
4
-
5
- export type Maybe<T> = T | null;
6
-
7
- export type Scalars = {
8
- ID: { input: string; output: string };
9
- String: { input: string; output: string };
10
- Boolean: { input: boolean; output: boolean };
11
- Int: { input: number; output: number };
12
- Float: { input: number; output: number };
13
- ARN: { input: any; output: any };
14
- Date: { input: any; output: any };
15
- DateTime: { input: any; output: any };
16
- Decimal: { input: any; output: any };
17
- FormattedString: { input: any; output: any };
18
- HTML: { input: any; output: any };
19
- JSON: { input: any; output: any };
20
- Money: { input: any; output: any };
21
- StorefrontID: { input: any; output: any };
22
- URL: { input: any; output: any };
23
- UnsignedInt64: { input: any; output: any };
24
- UtcOffset: { input: any; output: any };
25
- };
26
-
27
- /** Return type for `draftOrderCalculate` mutation. */
28
- export type DraftOrderCalculatePayload = {
29
- /** The calculated properties for a draft order. */
30
- calculatedDraftOrder?: Maybe<CalculatedDraftOrder>;
31
- };
32
-
33
- export interface CalculatedDraftOrder {
34
- /** The available shipping rates for the draft order. Requires a customer with a valid shipping address and at least one line item. */
35
- availableShippingRates: Array<ShippingRate>;
36
- }
37
-
38
- export type ShippingRate = {
39
- /** Human-readable unique identifier for this shipping rate. */
40
- handle: Scalars["String"]["output"];
41
- /** The cost associated with the shipping rate. */
42
- price: MoneyV2;
43
- /** The name of the shipping rate. */
44
- title: Scalars["String"]["output"];
45
- };
46
-
47
- export type MoneyV2 = {
48
- /** Decimal money amount. */
49
- amount: Scalars["Decimal"]["output"];
50
- /** Currency of the money. */
51
- currencyCode: CurrencyCode;
52
- };
53
-
54
- export enum CurrencyCode {
55
- CAD = "CAD",
56
- Usd = "USD",
57
- }
@@ -1,29 +0,0 @@
1
- import { gql } from "../graphql";
2
-
3
- // Fixme: This is to avoid typescript generation errors
4
- // because it does not accept generating an empty schema
5
- // TODO: Remove this once you add any other query
6
- export const Noop = {
7
- query: gql`query Noop { app(id: "") { description } }`,
8
- };
9
-
10
- export const draftOrderCalculate = {
11
- query: gql` mutation draftOrderCalculate($input: DraftOrderInput!) {
12
- calculatedDraftOrder: draftOrderCalculate(input: $input) {
13
- calculatedDraftOrder {
14
- availableShippingRates {
15
- title
16
- handle
17
- price {
18
- amount
19
- }
20
- }
21
- }
22
- userErrors {
23
- field
24
- message
25
- }
26
- }
27
- }
28
- `,
29
- };
package/src/utils/cart.ts DELETED
@@ -1,32 +0,0 @@
1
- import { getCookies, setCookie } from "./cookies";
2
-
3
- const CART_COOKIE = "cart";
4
- const SHOPIFY_PREFIX = "gid://shopify/Cart/";
5
-
6
- const ONE_WEEK_MS = 7 * 24 * 3600 * 1_000;
7
-
8
- export const getCartCookie = (headers: Headers): string | null => {
9
- const cookies = getCookies(headers);
10
-
11
- if (!cookies[CART_COOKIE]) {
12
- return null;
13
- }
14
-
15
- try {
16
- return decodeURIComponent(`${SHOPIFY_PREFIX}${cookies[CART_COOKIE]}`);
17
- } catch {
18
- return null;
19
- }
20
- };
21
-
22
- export const setCartCookie = (headers: Headers, cartId: string) => {
23
- setCookie(headers, {
24
- name: CART_COOKIE,
25
- value: cartId.replace(SHOPIFY_PREFIX, ""),
26
- path: "/",
27
- expires: new Date(Date.now() + ONE_WEEK_MS),
28
- httpOnly: true,
29
- secure: true,
30
- sameSite: "Lax",
31
- });
32
- };
@@ -1,82 +0,0 @@
1
- /**
2
- * Node.js-compatible cookie helpers.
3
- * Replaces Deno's std/http/cookie.ts.
4
- */
5
-
6
- export function getCookies(headers: Headers): Record<string, string> {
7
- const cookieHeader = headers.get("cookie") || "";
8
- const cookies: Record<string, string> = {};
9
- for (const pair of cookieHeader.split(";")) {
10
- const [key, ...rest] = pair.trim().split("=");
11
- if (key) {
12
- cookies[key.trim()] = decodeURIComponent(rest.join("=").trim());
13
- }
14
- }
15
- return cookies;
16
- }
17
-
18
- export function setCookie(
19
- headers: Headers,
20
- options: {
21
- name: string;
22
- value: string;
23
- path?: string;
24
- expires?: Date;
25
- maxAge?: number;
26
- httpOnly?: boolean;
27
- secure?: boolean;
28
- sameSite?: "Strict" | "Lax" | "None";
29
- },
30
- ) {
31
- const parts = [`${options.name}=${encodeURIComponent(options.value)}`];
32
- if (options.path) parts.push(`Path=${options.path}`);
33
- if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
34
- if (options.maxAge !== undefined) parts.push(`Max-Age=${options.maxAge}`);
35
- if (options.httpOnly) parts.push("HttpOnly");
36
- if (options.secure) parts.push("Secure");
37
- if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
38
-
39
- headers.append("Set-Cookie", parts.join("; "));
40
- }
41
-
42
- export interface Cookie {
43
- name: string;
44
- value: string;
45
- path?: string;
46
- expires?: Date;
47
- maxAge?: number;
48
- httpOnly?: boolean;
49
- secure?: boolean;
50
- sameSite?: "Strict" | "Lax" | "None";
51
- }
52
-
53
- export function getSetCookies(headers: Headers): Cookie[] {
54
- const cookies: Cookie[] = [];
55
- const setCookieHeaders = headers.getSetCookie?.() ?? [];
56
- for (const header of setCookieHeaders) {
57
- const parts = header.split(";").map((p) => p.trim());
58
- const [nameValue, ...attrs] = parts;
59
- const [name, ...rest] = nameValue.split("=");
60
- const value = rest.join("=");
61
- const cookie: Cookie = { name: name.trim(), value };
62
- for (const attr of attrs) {
63
- const [k, v] = attr.split("=");
64
- const key = k.trim().toLowerCase();
65
- if (key === "path") cookie.path = v?.trim();
66
- if (key === "httponly") cookie.httpOnly = true;
67
- if (key === "secure") cookie.secure = true;
68
- if (key === "samesite") cookie.sameSite = v?.trim() as any;
69
- if (key === "max-age") cookie.maxAge = Number(v?.trim());
70
- }
71
- cookies.push(cookie);
72
- }
73
- return cookies;
74
- }
75
-
76
- export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
77
- let timer: ReturnType<typeof setTimeout>;
78
- return ((...args: any[]) => {
79
- clearTimeout(timer);
80
- timer = setTimeout(() => fn(...args), delay);
81
- }) as T;
82
- }