diff --git a/.changeset/loud-gorillas-give.md b/.changeset/loud-gorillas-give.md
new file mode 100644
index 000000000..a845151cc
--- /dev/null
+++ b/.changeset/loud-gorillas-give.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/storefront-api-client/.gitignore b/packages/storefront-api-client/.gitignore
new file mode 100644
index 000000000..d5450fae9
--- /dev/null
+++ b/packages/storefront-api-client/.gitignore
@@ -0,0 +1,18 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+node_modules/
+dist/
+
+package-lock.json
+.vscode/
+.DS_Store
+.rollup.cache/
+
+# ignore any locally packed packages
+*.tgz
+!*.d.ts
diff --git a/packages/storefront-api-client/CODE_OF_CONDUCT.md b/packages/storefront-api-client/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..bb79d09b4
--- /dev/null
+++ b/packages/storefront-api-client/CODE_OF_CONDUCT.md
@@ -0,0 +1,131 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at opensource@shopify.com.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][mozilla coc].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][faq]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[mozilla coc]: https://github.com/mozilla/diversity
+[faq]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/packages/storefront-api-client/LICENSE.md b/packages/storefront-api-client/LICENSE.md
new file mode 100644
index 000000000..bf165a7b3
--- /dev/null
+++ b/packages/storefront-api-client/LICENSE.md
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) 2023-present, Shopify Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/storefront-api-client/README.md b/packages/storefront-api-client/README.md
new file mode 100644
index 000000000..abd3c7986
--- /dev/null
+++ b/packages/storefront-api-client/README.md
@@ -0,0 +1,375 @@
+# Storefront API Client
+
+The Storefront API Client manages the API's authentication information and provides various methods that enables devs to interacts with the API.
+
+## Initialization
+
+### Public access token client initialization
+
+```typescript
+import {createStorefrontApiClient} from '@shopify/storefront-api-client';
+
+const client = createStorefrontApiClient({
+ storeDomain: 'http://your-shop-name.myshopify.com',
+ apiVersion: '2023-10',
+ publicAccessToken: 'your-storefront-public-access-token',
+});
+```
+
+### Create a server enabled client using a private access token and a custom Fetch API
+
+> [!WARNING]
+> Private Storefront API delegate access tokens should only be used in server-to-server implementations and not within a browser environment.
+
+In order to use the client within a server, a server enabled JS Fetch API will need to be provided to the client at initialization.
+
+```typescript
+import {createStorefrontApiClient, CustomFetchApi} from '@shopify/storefront-api-client';
+import {fetch as nodeFetch} from 'node-fetch';
+
+const client = createStorefrontApiClient({
+ storeDomain: 'http://your-shop-name.myshopify.com',
+ apiVersion: '2023-10',
+ privateAccessToken: 'your-storefront-private-access-token',
+ customFetchApi: nodeFetch,
+});
+```
+
+### `createStorefrontApiClient()` parameters
+
+| Property | Type | Description |
+| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| storeDomain | `string` | The domain of the store. It can be the Shopify `myshopify.com` domain or a custom store domain. |
+| apiVersion | `string` | The requested Storefront API version |
+| publicAccessToken? | `string` | Storefront API public access token. Either `publicAccessToken` or `privateAccessToken` must be provided at initialization. |
+| privateAccessToken? | `string` | Storefront API private access token. Either `publicAccessToken` or `privateAccessToken` must be provided at initialization.
**Important:** Storefront API private delegate access tokens should only be used in a `server-to-server` implementation. |
+| clientName? | `string` | Name of the client |
+| retries? | `number` | The number of HTTP request retries if the request was abandoned or the server responded with a `Too Many Requests (429)` or `Service Unavailable (503)` response. Default value is `0`. Maximum value is `3`. |
+| customFetchAPI? | `(url: string, init?: {method?: string, headers?: HeaderInit, body?: string}) => Promise` | A replacement `fetch` function that will be used in all client network requests. By default, the client uses `window.fetch()`. |
+| logger? | `(logContent: `[UnsupportedApiVersionLog](#unsupportedapiversionlog) ` \| `[HTTPResponseLog](#httpresponselog)`\|`[HTTPRetryLog](#httpretrylog)`) => void` | A logger function that accepts [log content objects](#log-content-types). This logger will be called in certain conditions with contextual information. |
+
+
+## Client properties
+
+| Property | Type | Description |
+| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| config | [StorefrontApiClientConfig](#storefrontapiclientconfig-properties) | Configuration for the client |
+| getHeaders | `(customHeaders?: {[key: string]: string}) => {[key: string]: string}` | Returns Storefront API specific headers needed to interact with the API. If `customHeaders` is provided, the custom headers will be included in the returned headers object. |
+| getApiUrl | `(apiVersion?: string) => string` | Returns the shop specific API url. If an API version is provided, the returned URL will include the provided version, else the URL will include the API version set at client initialization. |
+| fetch | `(operation: string, options?: `[ApiClientRequestOptions](#apiclientrequestoptions-properties)`) => Promise` | Fetches data from Storefront API using the provided GQL `operation` string and [ApiClientRequestOptions](#apiclientrequestoptions-properties) object and returns the network response. |
+| request | `(operation: string, options?: `[ApiClientRequestOptions](#apiclientrequestoptions-properties)`) => Promise<`[ClientResponse\](#ClientResponsetdata)`>` | Requests data from Storefront API using the provided GQL `operation` string and [ApiClientRequestOptions](#apiclientrequestoptions-properties) object and returns a normalized response object. |
+
+
+## `StorefrontApiClientConfig` properties
+
+| Name | Type | Description |
+| -------------- | ------------------------ | ---------------------------------------------------- |
+| storeDomain | `string` | The secure store domain |
+| apiVersion | `string` | The Storefront API version to use in the API request |
+| publicAccessToken | `string \| null` | The provided public access token |
+| privateAccessToken | `string \| null` | The provided private access token |
+| headers | `{[key: string]: string}` | The headers generated by the client during initialization |
+| apiUrl | `string` | The API URL generated from the provided store domain and api version |
+| clientName? | `string` | The provided client name |
+| retries? | `number` | The number of retries the client will attempt when the API responds with a `Too Many Requests (429)` or `Service Unavailable (503)` response |
+
+
+## `ApiClientRequestOptions` properties
+
+| Name | Type | Description |
+| -------------- | ------------------------ | ---------------------------------------------------- |
+| variables? | `Record` | Variable values needed in the graphQL operation |
+| apiVersion? | `string` | The Storefront API version to use in the API request |
+| customHeaders? | `Record` | Customized headers to be included in the API request |
+| retries? | `number` | Alternative number of retries for the request. Retries only occur for requests that were abandoned or if the server responds with a `Too Many Request (429)` or `Service Unavailable (503)` response. Minimum value is `0` and maximum value is `3`.|
+
+## `ClientResponse`
+
+| Name | Type | Description |
+| ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| data? | `TData \| any` | Data returned from the Storefront API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. |
+| errors? | [ResponseErrors](#responseerrors) | Errors object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. |
+| extensions? | `{[key: string]: any}` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. |
+
+## `ResponseErrors`
+
+| Name | Type | Description |
+| ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| networkStatusCode? | `number` | HTTP response status code |
+| message? | `string` | The provided error message |
+| graphQLErrors? | `any[]` | The GraphQL API errors returned by the server |
+
+### Client `request()` response examples
+
+
+ Successful response
+
+### API response
+
+```json
+{
+ "data": {
+ "product": {
+ "id": "gid://shopify/Product/12345678912",
+ "title": "Sample product # 1"
+ }
+ },
+ "extensions": {
+ "context": {
+ "country": "US",
+ "language": "EN"
+ }
+ }
+}
+```
+
+
+
+
+ Error responses
+
+### Network error
+
+```json
+{
+ "errors": {
+ "networkStatusCode": 401,
+ "message": ""
+ }
+}
+```
+
+### Storefront API graphQL error
+
+```json
+{
+ "errors": {
+ "networkStatusCode": 200,
+ "message": "An error occurred while fetching from the API. Review the `graphQLErrors` object for details.",
+ "graphQLErrors": [
+ {
+ "message": "Field 'testField' doesn't exist on type 'Product'",
+ "locations": [
+ {
+ "line": 17,
+ "column": 3
+ }
+ ],
+ "path": ["fragment ProductFragment", "testField"],
+ "extensions": {
+ "code": "undefinedField",
+ "typeName": "Product",
+ "fieldName": "testField"
+ }
+ }
+ ]
+ }
+}
+```
+
+
+
+## Usage examples
+
+### Query for a product
+
+```typescript
+const productQuery = `
+ query ProductQuery($handle: String) {
+ product(handle: $handle) {
+ id
+ title
+ handle
+ }
+ }
+`;
+
+const {data, errors, extensions} = await client.request(productQuery, {
+ variables: {
+ handle: 'sample-product',
+ },
+});
+```
+
+### Create a localized cart
+
+```typescript
+const cartCreateMutation = `
+ mutation ($input: CartInput!, $country: CountryCode, $language: LanguageCode)
+ @inContext(country: $country, language: $language) {
+ cartCreate(input: $input) {
+ userErrors {
+ message
+ code
+ field
+ }
+ cart {
+ id
+ checkoutUrl
+ }
+ }
+ }
+`;
+
+const {data, errors, extensions} = await client.request(cartCreateMutation, {
+ variables: {
+ input: {},
+ country: 'JP',
+ language: 'JA',
+ },
+});
+```
+
+### Query for shop information
+
+```typescript
+const shopQuery = `
+ query shop {
+ shop {
+ name
+ id
+ }
+ }
+`;
+
+const {data, errors, extensions} = await client.request(shopQuery);
+```
+
+### Dynamically set the Storefront API version per request
+
+```typescript
+const productQuery = `
+ query ProductQuery($handle: String) {
+ product(handle: $handle) {
+ id
+ title
+ handle
+ }
+ }
+`;
+
+const {data, errors, extensions} = await client.request(productQuery, {
+ variables: {
+ handle: 'sample-product',
+ },
+ apiVersion: '2023-07',
+});
+```
+
+### Add custom headers to API request
+
+```typescript
+const productQuery = `
+ query ProductQuery($handle: String) {
+ product(handle: $handle) {
+ id
+ title
+ handle
+ }
+ }
+`;
+
+const {data, errors, extensions} = await client.request(productQuery, {
+ variables: {
+ handle: 'sample-product',
+ },
+ customHeaders: {
+ 'Shopify-Storefront-Id': 'shop-id',
+ },
+});
+```
+
+### Dynamically set the number of retries per request
+
+```typescript
+const productQuery = `
+ query ProductQuery($handle: String) {
+ product(handle: $handle) {
+ id
+ title
+ handle
+ }
+ }
+`;
+
+const {data, errors, extensions} = await client.request(productQuery, {
+ variables: {
+ handle: 'sample-product',
+ },
+ retries: 2,
+});
+```
+
+### Provide GQL query type to `client.request()`
+
+```typescript
+import {print} from 'graphql/language';
+
+// GQL operation types are usually auto generated during the application build
+import {CollectionQuery} from 'types/appTypes';
+import collectionQuery from './collectionQuery.graphql';
+
+const {data, error, extensions} = await client.request(
+ print(collectionQuery),
+ {
+ variables: {
+ handle: 'sample-collection',
+ },
+ }
+);
+```
+
+### Using `client.fetch()` to get API data
+
+```typescript
+const shopQuery = `
+ query shop {
+ shop {
+ name
+ id
+ }
+ }
+`;
+
+const response = await client.fetch(shopQuery);
+
+if (response.ok) {
+ const {errors, data, extensions} = await response.json();
+}
+```
+
+## Log Content Types
+
+### `UnsupportedApiVersionLog`
+
+This log content is sent to the logger whenever an unsupported API version is provided to the client.
+
+| Property | Type | Description |
+| -------- | ------------------------ | ---------------------------------- |
+| type | `LogType['UNSUPPORTED_API_VERSION']` | The type of log content. Is always set to `UNSUPPORTED_API_VERSION` |
+| content | `{apiVersion: string, supportedApiVersions: string[]}` | Contextual info including the provided API version and the list of currently supported API versions. |
+
+### `HTTPResponseLog`
+
+This log content is sent to the logger whenever a HTTP response is received by the client.
+
+| Property | Type | Description |
+| -------- | ------------------------ | ---------------------------------- |
+| type | `LogType['HTTP-Response']` | The type of log content. Is always set to `HTTP-Response` |
+| content | `{`[requestParams](#requestparams)`: [url, init?], response: Response}` | Contextual data regarding the request and received response |
+
+### `HTTPRetryLog`
+
+This log content is sent to the logger whenever the client attempts to retry HTTP requests.
+
+| Property | Type | Description |
+| -------- | ------------------------ | ---------------------------------- |
+| type | `LogType['HTTP-Retry']` | The type of log content. Is always set to `HTTP-Retry` |
+| content | `{`[requestParams](#requestparams)`: [url, init?], lastResponse?: Response, retryAttempt: number, maxRetries: number}` | Contextual data regarding the upcoming retry attempt.
`requestParams`: [parameters](#requestparams) used in the request
`lastResponse`: previous response
`retryAttempt`: the current retry attempt count
`maxRetries`: the maximum number of retries |
+
+### `RequestParams`
+
+| Property | Type | Description |
+| -------- | ------------------------ | ---------------------------------- |
+| url | `string` | Requested URL |
+| init? | `{method?: string, headers?: HeaderInit, body?: string}` | The request information |
diff --git a/packages/storefront-api-client/babel.config.json b/packages/storefront-api-client/babel.config.json
new file mode 100644
index 000000000..5182c456c
--- /dev/null
+++ b/packages/storefront-api-client/babel.config.json
@@ -0,0 +1,10 @@
+{
+ "presets": [
+ ["@babel/preset-env"],
+ ["@shopify/babel-preset", {"typescript": true}]
+ ],
+ "plugins": [
+ ["@babel/plugin-transform-runtime"],
+ ["@babel/plugin-transform-async-to-generator"]
+ ]
+}
diff --git a/packages/storefront-api-client/package.json b/packages/storefront-api-client/package.json
new file mode 100644
index 000000000..a0c2d9cc3
--- /dev/null
+++ b/packages/storefront-api-client/package.json
@@ -0,0 +1,106 @@
+{
+ "name": "@shopify/storefront-api-client",
+ "version": "0.0.0",
+ "description": "Shopify Storefront API Client - A lightweight JS client to interact with Shopify's Storefront API",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/Shopify/shopify-api-js.git"
+ },
+ "author": "Shopify",
+ "license": "MIT",
+ "main": "./dist/umd/storefront-api-client.min.js",
+ "browser": "./dist/umd/storefront-api-client.min.js",
+ "module": "./dist/index.mjs",
+ "types": "./dist/storefront-api-client.d.ts",
+ "exports": {
+ ".": {
+ "module": {
+ "types": "./dist/ts/index.d.ts",
+ "default": "./dist/index.mjs"
+ },
+ "import": {
+ "types": "./dist/ts/index.d.ts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/ts/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "default": "./dist/index.mjs"
+ }
+ },
+ "scripts": {
+ "lint": "eslint . --ext .js,.ts",
+ "build": "yarn run rollup",
+ "test": "jest",
+ "test:ci": "yarn test",
+ "rollup": "rollup -c --bundleConfigAsCjs",
+ "clean": "rimraf dist/*",
+ "changeset": "changeset",
+ "version": "changeset version",
+ "release": "yarn build && changeset publish"
+ },
+ "jest": {
+ "testEnvironment": "jsdom",
+ "setupFilesAfterEnv": [
+ "./src/tests/setupTests.ts"
+ ],
+ "transform": {
+ ".*": "babel-jest"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "keywords": [
+ "shopify",
+ "node",
+ "graphql",
+ "Storefront API"
+ ],
+ "files": [
+ "dist/**/*.*",
+ "!node_modules"
+ ],
+ "dependencies": {
+ "@shopify/graphql-client": "^0.6.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.21.3",
+ "@babel/plugin-transform-async-to-generator": "^7.20.7",
+ "@babel/plugin-transform-runtime": "^7.21.0",
+ "@babel/preset-env": "^7.20.2",
+ "@babel/preset-typescript": "^7.21.0",
+ "@changesets/changelog-github": "^0.4.8",
+ "@changesets/cli": "^2.26.1",
+ "@rollup/plugin-babel": "^6.0.3",
+ "@rollup/plugin-commonjs": "^24.0.1",
+ "@rollup/plugin-eslint": "^9.0.3",
+ "@rollup/plugin-node-resolve": "^15.0.1",
+ "@rollup/plugin-replace": "^5.0.2",
+ "@rollup/plugin-terser": "^0.4.0",
+ "@rollup/plugin-typescript": "^11.0.0",
+ "@shopify/babel-preset": "^25.0.0",
+ "@shopify/eslint-plugin": "^42.0.3",
+ "@shopify/prettier-config": "^1.1.2",
+ "@shopify/typescript-configs": "^5.1.0",
+ "@types/jest": "^29.5.0",
+ "@types/regenerator-runtime": "^0.13.1",
+ "@typescript-eslint/parser": "^6.7.5",
+ "babel-jest": "^29.5.0",
+ "eslint": "^8.51.0",
+ "jest": "^29.7.0",
+ "jest-environment-jsdom": "^29.5.0",
+ "jest-fetch-mock": "^3.0.3",
+ "prettier": "^2.5.1",
+ "regenerator-runtime": "^0.13.11",
+ "rollup": "^3.19.1",
+ "rollup-plugin-dts": "^5.2.0",
+ "tslib": "^2.5.0",
+ "typescript": "^5.2.0"
+ },
+ "bugs": {
+ "url": "https://github.com/Shopify/shopify-api-js/issues"
+ },
+ "homepage": "https://github.com/Shopify/shopify-api-js/packages/storefront-api-client#readme"
+}
diff --git a/packages/storefront-api-client/rollup.config.cjs b/packages/storefront-api-client/rollup.config.cjs
new file mode 100644
index 000000000..c50eaafdd
--- /dev/null
+++ b/packages/storefront-api-client/rollup.config.cjs
@@ -0,0 +1,101 @@
+import dts from "rollup-plugin-dts";
+import typescript from "@rollup/plugin-typescript";
+import resolve from "@rollup/plugin-node-resolve";
+import commonjs from "@rollup/plugin-commonjs";
+import terser from "@rollup/plugin-terser";
+import replace from "@rollup/plugin-replace";
+
+import * as pkg from "./package.json";
+
+export const mainSrcInput = "src/index.ts";
+
+export function getPlugins({ tsconfig, minify } = {}) {
+ return [
+ replace({
+ preventAssignment: true,
+ ROLLUP_REPLACE_CLIENT_VERSION: pkg.version,
+ }),
+ resolve(),
+ commonjs(),
+ typescript({
+ tsconfig: tsconfig ? tsconfig : "./tsconfig.build.json",
+ outDir: "./dist/ts",
+ }),
+ ...(minify === true ? [terser({ keep_fnames: new RegExp("fetch") })] : []),
+ ];
+}
+
+const packageName = pkg.name.substring(1);
+const repositoryName = pkg.repository.url.split(":")[1].split(".")[0];
+export const bannerConfig = {
+ banner: `/*! ${packageName} -- Copyright (c) 2023-present, Shopify Inc. -- license (MIT): https://github.com/${repositoryName}/blob/main/LICENSE */`,
+};
+
+const config = [
+ {
+ input: mainSrcInput,
+ plugins: getPlugins({
+ minify: true,
+ tsconfig: "./tsconfig.build.umd.json",
+ }),
+ output: [
+ {
+ file: "./dist/umd/storefront-api-client.min.js",
+ format: "umd",
+ sourcemap: true,
+ name: "ShopifyStorefrontAPIClient",
+ ...bannerConfig,
+ },
+ ],
+ },
+ {
+ input: mainSrcInput,
+ plugins: getPlugins({
+ tsconfig: "./tsconfig.build.umd.json",
+ }),
+ output: [
+ {
+ file: "./dist/umd/storefront-api-client.js",
+ format: "umd",
+ sourcemap: true,
+ name: "ShopifyStorefrontAPIClient",
+ ...bannerConfig,
+ },
+ ],
+ },
+ {
+ input: mainSrcInput,
+ plugins: getPlugins(),
+ output: [
+ {
+ dir: "./dist",
+ format: "es",
+ sourcemap: true,
+ preserveModules: true,
+ preserveModulesRoot: "src",
+ entryFileNames: "[name].mjs",
+ },
+ ],
+ },
+ {
+ input: mainSrcInput,
+ plugins: getPlugins(),
+ output: [
+ {
+ dir: "./dist",
+ format: "cjs",
+ sourcemap: true,
+ exports: "named",
+ preserveModules: true,
+ preserveModulesRoot: "src",
+ },
+ ],
+ },
+ {
+ input: "./dist/ts/index.d.ts",
+ output: [{ file: "dist/storefront-api-client.d.ts", format: "es" }],
+ plugins: [dts.default()],
+ },
+];
+
+export default config;
diff --git a/packages/storefront-api-client/src/constants.ts b/packages/storefront-api-client/src/constants.ts
new file mode 100644
index 000000000..7b9efca15
--- /dev/null
+++ b/packages/storefront-api-client/src/constants.ts
@@ -0,0 +1,12 @@
+export const DEFAULT_CONTENT_TYPE = "application/json";
+export const DEFAULT_SDK_VARIANT = "storefront-api-client";
+// This is value is replaced with package.json version during rollup build process
+export const DEFAULT_CLIENT_VERSION = "ROLLUP_REPLACE_CLIENT_VERSION";
+
+export const PUBLIC_ACCESS_TOKEN_HEADER = "X-Shopify-Storefront-Access-Token";
+export const PRIVATE_ACCESS_TOKEN_HEADER = "Shopify-Storefront-Private-Token";
+export const SDK_VARIANT_HEADER = "X-SDK-Variant";
+export const SDK_VERSION_HEADER = "X-SDK-Version";
+export const SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source";
+
+export const CLIENT = "Storefront API Client";
diff --git a/packages/storefront-api-client/src/index.ts b/packages/storefront-api-client/src/index.ts
new file mode 100644
index 000000000..5f93926b5
--- /dev/null
+++ b/packages/storefront-api-client/src/index.ts
@@ -0,0 +1 @@
+export { createStorefrontApiClient } from "./storefront-api-client";
diff --git a/packages/storefront-api-client/src/storefront-api-client.ts b/packages/storefront-api-client/src/storefront-api-client.ts
new file mode 100644
index 000000000..df90b179d
--- /dev/null
+++ b/packages/storefront-api-client/src/storefront-api-client.ts
@@ -0,0 +1,194 @@
+import {
+ createGraphQLClient,
+ RequestParams as GQLClientRequestParams,
+ getCurrentSupportedApiVersions,
+ validateDomainAndGetStoreUrl,
+ validateApiVersion,
+ ApiClientRequestParams,
+ ApiClientRequestOptions,
+} from "@shopify/graphql-client";
+
+import {
+ StorefrontApiClientOptions,
+ StorefrontApiClient,
+ StorefrontApiClientConfig,
+} from "./types";
+import {
+ DEFAULT_SDK_VARIANT,
+ DEFAULT_CLIENT_VERSION,
+ SDK_VARIANT_HEADER,
+ SDK_VARIANT_SOURCE_HEADER,
+ SDK_VERSION_HEADER,
+ DEFAULT_CONTENT_TYPE,
+ PUBLIC_ACCESS_TOKEN_HEADER,
+ PRIVATE_ACCESS_TOKEN_HEADER,
+ CLIENT,
+} from "./constants";
+import {
+ validateRequiredAccessTokens,
+ validatePrivateAccessTokenUsage,
+} from "./validations";
+
+export function createStorefrontApiClient({
+ storeDomain,
+ apiVersion,
+ publicAccessToken,
+ privateAccessToken,
+ clientName,
+ retries = 0,
+ customFetchApi: clientFetchApi,
+ logger,
+}: StorefrontApiClientOptions): StorefrontApiClient {
+ const currentSupportedApiVersions = getCurrentSupportedApiVersions();
+
+ const storeUrl = validateDomainAndGetStoreUrl({
+ client: CLIENT,
+ storeDomain,
+ });
+
+ const baseApiVersionValidationParams = {
+ client: CLIENT,
+ currentSupportedApiVersions,
+ logger,
+ };
+
+ validateApiVersion({ ...baseApiVersionValidationParams, apiVersion });
+ validateRequiredAccessTokens(publicAccessToken, privateAccessToken);
+ validatePrivateAccessTokenUsage(privateAccessToken);
+
+ const apiUrlFormatter = generateApiUrlFormatter(
+ storeUrl,
+ apiVersion,
+ baseApiVersionValidationParams
+ );
+
+ const config: StorefrontApiClientConfig = {
+ storeDomain: storeUrl,
+ apiVersion,
+ ...(publicAccessToken
+ ? { publicAccessToken }
+ : {
+ privateAccessToken: privateAccessToken!,
+ }),
+ headers: {
+ "Content-Type": DEFAULT_CONTENT_TYPE,
+ Accept: DEFAULT_CONTENT_TYPE,
+ [SDK_VARIANT_HEADER]: DEFAULT_SDK_VARIANT,
+ [SDK_VERSION_HEADER]: DEFAULT_CLIENT_VERSION,
+ ...(clientName ? { [SDK_VARIANT_SOURCE_HEADER]: clientName } : {}),
+ ...(publicAccessToken
+ ? { [PUBLIC_ACCESS_TOKEN_HEADER]: publicAccessToken }
+ : { [PRIVATE_ACCESS_TOKEN_HEADER]: privateAccessToken! }),
+ },
+ apiUrl: apiUrlFormatter(),
+ clientName,
+ };
+
+ const graphqlClient = createGraphQLClient({
+ headers: config.headers,
+ url: config.apiUrl,
+ retries,
+ fetchApi: clientFetchApi,
+ logger,
+ });
+
+ const getHeaders = generateGetHeader(config);
+ const getApiUrl = generateGetApiUrl(config, apiUrlFormatter);
+
+ const getGQLClientRequestProps = generateGetGQLClientProps({
+ getHeaders,
+ getApiUrl,
+ });
+
+ const fetch = (...props: ApiClientRequestParams) => {
+ const requestProps = getGQLClientRequestProps(...props);
+ return graphqlClient.fetch(...requestProps);
+ };
+
+ const request = (...props: ApiClientRequestParams) => {
+ const requestProps = getGQLClientRequestProps(...props);
+ return graphqlClient.request(...requestProps);
+ };
+
+ const client: StorefrontApiClient = {
+ config,
+ getHeaders,
+ getApiUrl,
+ fetch,
+ request,
+ };
+
+ return Object.freeze(client);
+}
+
+function generateApiUrlFormatter(
+ storeUrl: string,
+ defaultApiVersion: string,
+ baseApiVersionValidationParams: Omit<
+ Parameters[0],
+ "apiVersion"
+ >
+) {
+ return (apiVersion?: string) => {
+ if (apiVersion) {
+ validateApiVersion({
+ ...baseApiVersionValidationParams,
+ apiVersion,
+ });
+ }
+
+ const urlApiVersion = (apiVersion ?? defaultApiVersion).trim();
+
+ return `${storeUrl}/api/${urlApiVersion}/graphql.json`;
+ };
+}
+
+function generateGetHeader(
+ config: StorefrontApiClientConfig
+): StorefrontApiClient["getHeaders"] {
+ return (customHeaders) => {
+ return { ...(customHeaders ?? {}), ...config.headers };
+ };
+}
+
+function generateGetApiUrl(
+ config: StorefrontApiClientConfig,
+ apiUrlFormatter: (version?: string) => string
+): StorefrontApiClient["getApiUrl"] {
+ return (propApiVersion?: string) => {
+ return propApiVersion ? apiUrlFormatter(propApiVersion) : config.apiUrl;
+ };
+}
+
+function generateGetGQLClientProps({
+ getHeaders,
+ getApiUrl,
+}: {
+ getHeaders: StorefrontApiClient["getHeaders"];
+ getApiUrl: StorefrontApiClient["getApiUrl"];
+}) {
+ return (
+ operation: string,
+ options?: ApiClientRequestOptions
+ ): GQLClientRequestParams => {
+ const props: GQLClientRequestParams = [operation];
+
+ if (options) {
+ const {
+ variables,
+ apiVersion: propApiVersion,
+ customHeaders,
+ retries,
+ } = options;
+
+ props.push({
+ variables,
+ headers: customHeaders ? getHeaders(customHeaders) : undefined,
+ url: propApiVersion ? getApiUrl(propApiVersion) : undefined,
+ retries,
+ });
+ }
+
+ return props;
+ };
+}
diff --git a/packages/storefront-api-client/src/tests/setupTests.ts b/packages/storefront-api-client/src/tests/setupTests.ts
new file mode 100644
index 000000000..50dec607e
--- /dev/null
+++ b/packages/storefront-api-client/src/tests/setupTests.ts
@@ -0,0 +1 @@
+import "regenerator-runtime/runtime";
diff --git a/packages/storefront-api-client/src/tests/storefront-api-client.test.ts b/packages/storefront-api-client/src/tests/storefront-api-client.test.ts
new file mode 100644
index 000000000..2caec6bc4
--- /dev/null
+++ b/packages/storefront-api-client/src/tests/storefront-api-client.test.ts
@@ -0,0 +1,659 @@
+import { createGraphQLClient, GraphQLClient } from "@shopify/graphql-client";
+
+import { createStorefrontApiClient } from "../storefront-api-client";
+import { StorefrontApiClient } from "../types";
+import {
+ SDK_VARIANT_HEADER,
+ DEFAULT_SDK_VARIANT,
+ SDK_VERSION_HEADER,
+ DEFAULT_CLIENT_VERSION,
+ SDK_VARIANT_SOURCE_HEADER,
+ PUBLIC_ACCESS_TOKEN_HEADER,
+ PRIVATE_ACCESS_TOKEN_HEADER,
+ DEFAULT_CONTENT_TYPE,
+} from "../constants";
+
+const mockApiVersions = [
+ "2023-01",
+ "2023-04",
+ "2023-07",
+ "2023-10",
+ "2024-01",
+ "unstable",
+];
+
+jest.mock("@shopify/graphql-client", () => {
+ return {
+ ...jest.requireActual("@shopify/graphql-client"),
+ createGraphQLClient: jest.fn(),
+ getCurrentSupportedAPIVersions: () => mockApiVersions,
+ };
+});
+
+describe("Storefront API Client", () => {
+ describe("createStorefrontApiClient()", () => {
+ const domain = "test-store.myshopify.io";
+ const config = {
+ storeDomain: `https://${domain}`,
+ apiVersion: "2023-10",
+ publicAccessToken: "public-token",
+ };
+ const mockApiUrl = `${config.storeDomain}/api/2023-10/graphql.json`;
+
+ const mockFetchResponse = { status: 200 };
+ const mockRequestResponse = {
+ data: {},
+ };
+
+ const graphqlClientMock: GraphQLClient = {
+ config: {
+ url: mockApiUrl,
+ headers: {},
+ retries: 0,
+ },
+ fetch: jest.fn(),
+ request: jest.fn(),
+ };
+
+ beforeEach(() => {
+ (createGraphQLClient as jest.Mock).mockReturnValue(graphqlClientMock);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ describe("client initialization", () => {
+ it("calls the graphql client with headers and API URL", () => {
+ const clientName = "test-client";
+
+ createStorefrontApiClient({ ...config, clientName });
+ expect(
+ (createGraphQLClient as jest.Mock).mock.calls[0][0]
+ ).toHaveProperty("headers", {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "X-Shopify-Storefront-Access-Token": "public-token",
+ [SDK_VARIANT_HEADER]: DEFAULT_SDK_VARIANT,
+ [SDK_VERSION_HEADER]: DEFAULT_CLIENT_VERSION,
+ [SDK_VARIANT_SOURCE_HEADER]: clientName,
+ });
+ expect(
+ (createGraphQLClient as jest.Mock).mock.calls[0][0]
+ ).toHaveProperty("url", mockApiUrl);
+ });
+
+ it("calls the graphql client with the default retries", () => {
+ createStorefrontApiClient({ ...config });
+
+ expect(createGraphQLClient).toHaveBeenCalled();
+ expect(
+ (createGraphQLClient as jest.Mock).mock.calls[0][0]
+ ).toHaveProperty("retries", 0);
+ });
+
+ it("calls the graphql client with the provided retries", () => {
+ const retries = 1;
+
+ createStorefrontApiClient({ ...config, retries });
+
+ expect(createGraphQLClient).toHaveBeenCalled();
+ expect(
+ (createGraphQLClient as jest.Mock).mock.calls[0][0]
+ ).toHaveProperty("retries", retries);
+ });
+
+ it("calls the graphql client with the provided customFetchAPI", () => {
+ const customFetchApi = jest.fn();
+
+ createStorefrontApiClient({ ...config, customFetchApi });
+
+ expect(createGraphQLClient).toHaveBeenCalled();
+ expect(
+ (createGraphQLClient as jest.Mock).mock.calls[0][0]
+ ).toHaveProperty("fetchApi", customFetchApi);
+ });
+
+ it("calls the graphql client with the provided logger", () => {
+ const logger = jest.fn();
+
+ createStorefrontApiClient({ ...config, logger });
+
+ expect(createGraphQLClient).toHaveBeenCalled();
+ expect(
+ (createGraphQLClient as jest.Mock).mock.calls[0][0]
+ ).toHaveProperty("logger", logger);
+ });
+
+ it("returns a client object that contains a config object, getters for header and API URL and request and fetch functions", () => {
+ const client = createStorefrontApiClient(config);
+
+ expect(client).toHaveProperty("config");
+ expect(client).toMatchObject({
+ getHeaders: expect.any(Function),
+ getApiUrl: expect.any(Function),
+ request: expect.any(Function),
+ fetch: expect.any(Function),
+ });
+ });
+
+ describe("validations", () => {
+ it("throws an error when a store domain is not provided", () => {
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ storeDomain: undefined as any,
+ })
+ ).toThrow(
+ new Error(
+ 'Storefront API Client: a valid store domain ("undefined") must be provided'
+ )
+ );
+ });
+
+ it("throws an error when an empty string is provided as the store domain", () => {
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ storeDomain: " ",
+ })
+ ).toThrow(
+ new Error(
+ 'Storefront API Client: a valid store domain (" ") must be provided'
+ )
+ );
+ });
+
+ it("throws an error when the provided store domain is not a string", () => {
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ storeDomain: 123 as any,
+ })
+ ).toThrow(
+ new Error(
+ 'Storefront API Client: a valid store domain ("123") must be provided'
+ )
+ );
+ });
+
+ it("throws an error when the api version is not provided", () => {
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ apiVersion: undefined as any,
+ })
+ ).toThrow(
+ new Error(
+ `Storefront API Client: the provided apiVersion ("undefined") is invalid. Current supported API versions: ${mockApiVersions.join(
+ ", "
+ )}`
+ )
+ );
+ });
+
+ it("throws an error when the api version is not a string", () => {
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ apiVersion: { year: 2022, month: 1 } as any,
+ })
+ ).toThrow(
+ new Error(
+ `Storefront API Client: the provided apiVersion ("[object Object]") is invalid. Current supported API versions: ${mockApiVersions.join(
+ ", "
+ )}`
+ )
+ );
+ });
+
+ it("console warns when a unsupported api version is provided", () => {
+ const consoleWarnSpy = jest
+ .spyOn(window.console, "warn")
+ .mockImplementation(jest.fn());
+
+ createStorefrontApiClient({
+ ...config,
+ apiVersion: "2022-07",
+ });
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ `Storefront API Client: the provided apiVersion ("2022-07") is deprecated or not supported. Current supported API versions: ${mockApiVersions.join(
+ ", "
+ )}`
+ );
+ });
+
+ it("throws an error when neither public and private access tokens are provided", () => {
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ publicAccessToken: undefined as any,
+ })
+ ).toThrow(
+ new Error(
+ `Storefront API Client: a public or private access token must be provided`
+ )
+ );
+ });
+
+ it("throws an error when a private access token is provided in a browser environment (window is defined)", () => {
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ publicAccessToken: undefined as any,
+ privateAccessToken: "private-access-token",
+ })
+ ).toThrow(
+ new Error(
+ "Storefront API Client: private access tokens and headers should only be used in a server-to-server implementation. Use the public API access token in nonserver environments."
+ )
+ );
+ });
+
+ it("throws an error when both public and private access tokens are provided in a server environment (window is undefined)", () => {
+ const windowSpy = jest
+ .spyOn(window, "window", "get")
+ .mockImplementation(() => undefined as any);
+
+ expect(() =>
+ createStorefrontApiClient({
+ ...config,
+ privateAccessToken: "private-token",
+ } as any)
+ ).toThrow(
+ new Error(
+ `Storefront API Client: only provide either a public or private access token`
+ )
+ );
+
+ windowSpy.mockRestore();
+ });
+ });
+ });
+
+ describe("client config", () => {
+ it("returns a config object that includes the provided store domain", () => {
+ const client = createStorefrontApiClient(config);
+ expect(client.config.storeDomain).toBe(`https://${domain}`);
+ });
+
+ it("returns a config object that includes the provided public access token and not a private access token", () => {
+ const client = createStorefrontApiClient(config);
+ expect(client.config.publicAccessToken).toBe(config.publicAccessToken);
+ expect(client.config.privateAccessToken).toBeUndefined();
+ });
+
+ it("returns a config object that includes the provided private access token and not a public access token when in a server environment (window is undefined)", () => {
+ const windowSpy = jest
+ .spyOn(window, "window", "get")
+ .mockImplementation(() => undefined as any);
+
+ const privateAccessToken = "private-token";
+
+ const client = createStorefrontApiClient({
+ ...config,
+ publicAccessToken: undefined,
+ privateAccessToken,
+ });
+ expect(client.config.privateAccessToken).toBe(privateAccessToken);
+ expect(client.config.publicAccessToken).toBeUndefined();
+
+ windowSpy.mockRestore();
+ });
+
+ it("returns a config object that includes the provided client name", () => {
+ const clientName = "test-client";
+
+ const client = createStorefrontApiClient({ ...config, clientName });
+ expect(client.config.clientName).toBe(clientName);
+ });
+
+ describe("API url", () => {
+ const cleanedStoreDomain = "test-store.myshopify.io";
+ const expectedAPIUrl = `https://${cleanedStoreDomain}/api/${config.apiVersion}/graphql.json`;
+
+ it("returns a config object that includes the secure API url constructed with the provided API version and a store domain that includes 'https'", () => {
+ const client = createStorefrontApiClient({
+ ...config,
+ storeDomain: `https://${cleanedStoreDomain}`,
+ });
+ expect(client.config.apiUrl).toBe(expectedAPIUrl);
+ });
+
+ it("returns a config object that includes the secure API url constructed with the provided API version and a store domain that includes a non-secure protocol", () => {
+ const client = createStorefrontApiClient({
+ ...config,
+ storeDomain: `http://${cleanedStoreDomain}`,
+ });
+ expect(client.config.apiUrl).toBe(expectedAPIUrl);
+ });
+
+ it("returns a config object that includes the secure API url constructed with the provided API version and a store domain that does not include a protocol", () => {
+ const client = createStorefrontApiClient({
+ ...config,
+ storeDomain: cleanedStoreDomain,
+ });
+ expect(client.config.apiUrl).toBe(expectedAPIUrl);
+ });
+
+ it("returns a config object that includes a valid API url constructed with the provided spaced out API version and a store domain", () => {
+ const client = createStorefrontApiClient({
+ ...config,
+ storeDomain: ` ${cleanedStoreDomain} `,
+ apiVersion: ` ${config.apiVersion} `,
+ });
+ expect(client.config.apiUrl).toBe(expectedAPIUrl);
+ });
+ });
+
+ describe("config headers", () => {
+ it("returns a header object that includes the content-type header", () => {
+ const client = createStorefrontApiClient(config);
+ expect(client.config.headers["Content-Type"]).toBe(
+ DEFAULT_CONTENT_TYPE
+ );
+ });
+
+ it("returns a header object that includes the accept header", () => {
+ const client = createStorefrontApiClient(config);
+ expect(client.config.headers.Accept).toBe(DEFAULT_CONTENT_TYPE);
+ });
+
+ it("returns a header object that includes the SDK variant header", () => {
+ const client = createStorefrontApiClient(config);
+ expect(client.config.headers[SDK_VARIANT_HEADER]).toBe(
+ DEFAULT_SDK_VARIANT
+ );
+ });
+
+ it("returns a header object that includes the SDK version header", () => {
+ const client = createStorefrontApiClient(config);
+ expect(client.config.headers[SDK_VERSION_HEADER]).toBe(
+ DEFAULT_CLIENT_VERSION
+ );
+ });
+
+ it("returns a header object that includes the public headers when a public access token is provided", () => {
+ const client = createStorefrontApiClient(config);
+ expect(client.config.headers[PUBLIC_ACCESS_TOKEN_HEADER]).toEqual(
+ config.publicAccessToken
+ );
+ });
+
+ it("returns a header object that includes the private headers when a private access token is provided", () => {
+ const windowSpy = jest
+ .spyOn(window, "window", "get")
+ .mockImplementation(() => undefined as any);
+
+ const privateAccessToken = "private-token";
+
+ const client = createStorefrontApiClient({
+ ...config,
+ publicAccessToken: undefined,
+ privateAccessToken,
+ });
+
+ expect(client.config.headers[PRIVATE_ACCESS_TOKEN_HEADER]).toEqual(
+ privateAccessToken
+ );
+
+ windowSpy.mockRestore();
+ });
+
+ it("returns a header object that includes the SDK variant source header when client name is provided", () => {
+ const clientName = "test-client";
+
+ const client = createStorefrontApiClient({ ...config, clientName });
+ expect(client.config.headers[SDK_VARIANT_SOURCE_HEADER]).toEqual(
+ clientName
+ );
+ });
+
+ it("returns a header object that does not include the SDK variant source header when client name is not provided", () => {
+ const client = createStorefrontApiClient(config);
+ expect(
+ client.config.headers[SDK_VARIANT_SOURCE_HEADER]
+ ).toBeUndefined();
+ });
+ });
+ });
+
+ describe("getHeaders()", () => {
+ let client: StorefrontApiClient;
+
+ beforeEach(() => {
+ client = createStorefrontApiClient(config);
+ });
+
+ it("returns the client's default headers if no custom headers are provided", () => {
+ const headers = client.getHeaders();
+ expect(headers).toEqual(client.config.headers);
+ });
+
+ it("returns a headers object that contains both the client default headers and the provided custom headers", () => {
+ const customHeaders = {
+ "Shopify-Storefront-Id": "test-id",
+ };
+ const headers = client.getHeaders(customHeaders);
+ expect(headers).toEqual({ ...customHeaders, ...client.config.headers });
+ });
+ });
+
+ describe("getApiUrl()", () => {
+ let client: StorefrontApiClient;
+
+ beforeEach(() => {
+ client = createStorefrontApiClient(config);
+ });
+
+ it("returns the client's default API url if no API version was provided", () => {
+ const url = client.getApiUrl();
+ expect(url).toBe(client.config.apiUrl);
+ });
+
+ it("returns an API url that is directed at the provided api version", () => {
+ const version = "unstable";
+ const url = client.getApiUrl(version);
+ expect(url).toEqual(
+ `${config.storeDomain}/api/${version}/graphql.json`
+ );
+ });
+
+ it("throws an error when the api version is not a string", () => {
+ const version = 123;
+ expect(() => client.getApiUrl(version as any)).toThrow(
+ new Error(
+ `Storefront API Client: the provided apiVersion ("123") is invalid. Current supported API versions: ${mockApiVersions.join(
+ ", "
+ )}`
+ )
+ );
+ });
+
+ it("console warns when a unsupported api version is provided", () => {
+ const consoleWarnSpy = jest
+ .spyOn(window.console, "warn")
+ .mockImplementation(jest.fn());
+
+ const version = "2021-01";
+ client.getApiUrl(version);
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ `Storefront API Client: the provided apiVersion ("2021-01") is deprecated or not supported. Current supported API versions: ${mockApiVersions.join(
+ ", "
+ )}`
+ );
+ });
+ });
+
+ describe("fetch()", () => {
+ let client: StorefrontApiClient;
+ const operation = `
+ query products{
+ products(first: 1) {
+ nodes {
+ id
+ title
+ }
+ }
+ }
+ `;
+
+ beforeEach(() => {
+ (graphqlClientMock.fetch as jest.Mock).mockResolvedValue(
+ mockFetchResponse
+ );
+
+ client = createStorefrontApiClient(config);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe("parameters", () => {
+ it("calls the graphql client fetch() with just the operation string when there is no provided options", async () => {
+ const test = await client.fetch(operation);
+
+ expect(graphqlClientMock.fetch).toHaveBeenCalledWith(operation);
+ });
+
+ it("calls the graphql client fetch() with provided variables", async () => {
+ const variables = { first: 1 };
+
+ await client.fetch(operation, { variables });
+ expect((graphqlClientMock.fetch as jest.Mock).mock.calls[0][0]).toBe(
+ operation
+ );
+ expect(
+ (graphqlClientMock.fetch as jest.Mock).mock.calls[0][1]
+ ).toEqual({ variables });
+ });
+
+ it("calls the graphql client fetch() with customized headers", async () => {
+ const customHeaders = { "custom-header": "custom" };
+
+ await client.fetch(operation, { customHeaders });
+ expect(
+ (graphqlClientMock.fetch as jest.Mock).mock.calls.pop()[1]
+ ).toEqual({
+ headers: client.getHeaders(customHeaders),
+ });
+ });
+
+ it("calls the graphql client fetch() with provided api version URL", async () => {
+ const apiVersion = "unstable";
+
+ await client.fetch(operation, { apiVersion });
+ expect(
+ (graphqlClientMock.fetch as jest.Mock).mock.calls.pop()[1]
+ ).toEqual({
+ url: client.getApiUrl(apiVersion),
+ });
+ });
+
+ it("calls the graphql client fetch() with provided retries", async () => {
+ const retries = 2;
+
+ await client.fetch(operation, { retries });
+ expect(
+ (graphqlClientMock.fetch as jest.Mock).mock.calls.pop()[1]
+ ).toEqual({
+ retries,
+ });
+ });
+ });
+
+ it("returns the graphql client fetch response", async () => {
+ const response = await client.fetch(operation);
+ expect(response).toBe(mockFetchResponse);
+ });
+ });
+
+ describe("request()", () => {
+ let client: StorefrontApiClient;
+ const operation = `
+ query products{
+ products(first: 1) {
+ nodes {
+ id
+ title
+ }
+ }
+ }
+ `;
+
+ beforeEach(() => {
+ (graphqlClientMock.request as jest.Mock).mockResolvedValue(
+ mockRequestResponse
+ );
+
+ client = createStorefrontApiClient(config);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe("parameters", () => {
+ it("calls the graphql client request() with just the operation string when there is no provided options", async () => {
+ const test = await client.request(operation);
+
+ expect(graphqlClientMock.request).toHaveBeenCalledWith(operation);
+ });
+
+ it("calls the graphql client request() with provided variables", async () => {
+ const variables = { first: 1 };
+
+ await client.request(operation, { variables });
+ expect(
+ (graphqlClientMock.request as jest.Mock).mock.calls[0][0]
+ ).toBe(operation);
+ expect(
+ (graphqlClientMock.request as jest.Mock).mock.calls[0][1]
+ ).toEqual({ variables });
+ });
+
+ it("calls the graphql client request() with customized headers", async () => {
+ const customHeaders = { "custom-header": "custom" };
+
+ await client.request(operation, { customHeaders });
+ expect(
+ (graphqlClientMock.request as jest.Mock).mock.calls.pop()[1]
+ ).toEqual({
+ headers: client.getHeaders(customHeaders),
+ });
+ });
+
+ it("calls the graphql client request() with provided api version URL", async () => {
+ const apiVersion = "unstable";
+
+ await client.request(operation, { apiVersion });
+ expect(
+ (graphqlClientMock.request as jest.Mock).mock.calls.pop()[1]
+ ).toEqual({
+ url: client.getApiUrl(apiVersion),
+ });
+ });
+
+ it("calls the graphql client request() with provided retries", async () => {
+ const retries = 2;
+
+ await client.request(operation, { retries });
+ expect(
+ (graphqlClientMock.request as jest.Mock).mock.calls.pop()[1]
+ ).toEqual({
+ retries,
+ });
+ });
+ });
+
+ it("returns the graphql client request response", async () => {
+ const response = await client.request(operation);
+ expect(response).toBe(mockRequestResponse);
+ });
+ });
+ });
+});
diff --git a/packages/storefront-api-client/src/tests/validations.test.ts b/packages/storefront-api-client/src/tests/validations.test.ts
new file mode 100644
index 000000000..a40d88116
--- /dev/null
+++ b/packages/storefront-api-client/src/tests/validations.test.ts
@@ -0,0 +1,45 @@
+import {
+ validatePrivateAccessTokenUsage,
+ validateRequiredAccessTokens,
+} from "../validations";
+
+describe("validateRequiredAccessToken()", () => {
+ it("throws an error when both public and private tokens are undefined", () => {
+ const publicAccessToken = undefined;
+ const privateAccessToken = undefined;
+
+ expect(() =>
+ validateRequiredAccessTokens(publicAccessToken, privateAccessToken)
+ ).toThrow(
+ new Error(
+ "Storefront API Client: a public or private access token must be provided"
+ )
+ );
+ });
+});
+
+describe("validatePrivateAccessTokenUsage()", () => {
+ it("throws an error when private token is provided within a browser environment (window is defined)", () => {
+ const privateAccessToken = "private-token";
+
+ expect(() => validatePrivateAccessTokenUsage(privateAccessToken)).toThrow(
+ new Error(
+ "Storefront API Client: private access tokens and headers should only be used in a server-to-server implementation. Use the public API access token in nonserver environments."
+ )
+ );
+ });
+
+ it("does not throw an error when only the private token is provided when within a server environment (window is undefined)", () => {
+ const windowSpy = jest
+ .spyOn(window, "window", "get")
+ .mockImplementation(() => undefined as any);
+
+ const privateAccessToken = "private-token";
+
+ expect(() =>
+ validatePrivateAccessTokenUsage(privateAccessToken)
+ ).not.toThrow();
+
+ windowSpy.mockRestore();
+ });
+});
diff --git a/packages/storefront-api-client/src/types.ts b/packages/storefront-api-client/src/types.ts
new file mode 100644
index 000000000..ec06c9980
--- /dev/null
+++ b/packages/storefront-api-client/src/types.ts
@@ -0,0 +1,39 @@
+import {
+ ApiClient,
+ Headers,
+ CustomFetchApi,
+ ApiClientLogger,
+ ApiClientLogContentTypes,
+} from "@shopify/graphql-client";
+
+export type StorefrontApiClientLogContentTypes = ApiClientLogContentTypes;
+
+export type StorefrontApiClientConfig = {
+ storeDomain: string;
+ apiVersion: string;
+ headers: Headers;
+ apiUrl: string;
+ retries?: number;
+ clientName?: string;
+} & (
+ | {
+ publicAccessToken?: never;
+ privateAccessToken: string;
+ }
+ | {
+ publicAccessToken: string;
+ privateAccessToken?: never;
+ }
+);
+
+export type StorefrontApiClientOptions = Omit<
+ StorefrontApiClientConfig,
+ "headers" | "apiUrl"
+> & {
+ customFetchApi?: CustomFetchApi;
+ logger?: ApiClientLogger;
+};
+
+export type StorefrontApiClient = ApiClient<
+ Readonly
+>;
diff --git a/packages/storefront-api-client/src/validations.ts b/packages/storefront-api-client/src/validations.ts
new file mode 100644
index 000000000..89b955aec
--- /dev/null
+++ b/packages/storefront-api-client/src/validations.ts
@@ -0,0 +1,28 @@
+import { CLIENT } from "./constants";
+
+export function validatePrivateAccessTokenUsage(
+ privateAccessToken: string | undefined
+) {
+ if (privateAccessToken && window) {
+ throw new Error(
+ `${CLIENT}: private access tokens and headers should only be used in a server-to-server implementation. Use the public API access token in nonserver environments.`
+ );
+ }
+}
+
+export function validateRequiredAccessTokens(
+ publicAccessToken: string | undefined,
+ privateAccessToken: string | undefined
+) {
+ if (!publicAccessToken && !privateAccessToken) {
+ throw new Error(
+ `${CLIENT}: a public or private access token must be provided`
+ );
+ }
+
+ if (publicAccessToken && privateAccessToken) {
+ throw new Error(
+ `${CLIENT}: only provide either a public or private access token`
+ );
+ }
+}
diff --git a/packages/storefront-api-client/tsconfig.build.json b/packages/storefront-api-client/tsconfig.build.json
new file mode 100644
index 000000000..763a46828
--- /dev/null
+++ b/packages/storefront-api-client/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["./node_modules", "./src/**/tests/*.ts"]
+}
diff --git a/packages/storefront-api-client/tsconfig.build.umd.json b/packages/storefront-api-client/tsconfig.build.umd.json
new file mode 100644
index 000000000..4a8d809ac
--- /dev/null
+++ b/packages/storefront-api-client/tsconfig.build.umd.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.build.json",
+ "compilerOptions": {
+ "declaration": false,
+ "declarationMap": false
+ }
+}
diff --git a/packages/storefront-api-client/tsconfig.json b/packages/storefront-api-client/tsconfig.json
new file mode 100644
index 000000000..5e4a73bed
--- /dev/null
+++ b/packages/storefront-api-client/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "include": ["./src/**/*.ts"],
+ "exclude": ["./node_modules"],
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "target": "ES2022",
+ "lib": ["ESNext", "DOM"],
+ "rootDir": "src",
+ "baseUrl": "src",
+ "strict": true,
+ "pretty": true,
+ "allowSyntheticDefaultImports": true,
+ "strictPropertyInitialization": true
+ },
+ "extends": "../../tsconfig.base.json",
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index b398e3ed3..850b72731 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -23,6 +23,7 @@
},
"references": [
{"path": "./packages/shopify-api"},
- {"path": "./packages/graphql-client"}
+ {"path": "./packages/graphql-client"},
+ {"path": "./packages/storefront-api-client"}
]
}