├── web
├── shopify.web.toml
├── frontend
│ ├── components
│ │ ├── index.ts
│ │ ├── providers
│ │ │ ├── index.js
│ │ │ ├── QueryProvider.jsx
│ │ │ ├── PolarisProvider.jsx
│ │ │ └── AppBridgeProvider.jsx
│ │ └── ProductsCard.tsx
│ ├── shopify.web.toml
│ ├── prettierrc.json
│ ├── assets
│ │ ├── home-trophy.png
│ │ ├── index.js
│ │ └── empty-state.svg
│ ├── hooks
│ │ ├── index.ts
│ │ ├── useAppQuery.ts
│ │ └── useAuthenticatedFetch.ts
│ ├── index.tsx
│ ├── dev_embed.js
│ ├── .gitignore
│ ├── .eslintrc.cjs
│ ├── pages
│ │ ├── NotFound.tsx
│ │ ├── ExitIframe.tsx
│ │ ├── pagename.tsx
│ │ └── index.tsx
│ ├── README.md
│ ├── tsconfig.json
│ ├── index.html
│ ├── App.tsx
│ ├── package.json
│ ├── vite.config.js
│ └── Routes.tsx
├── environment.ts
├── helpers
│ ├── return-top-level-redirection.js
│ ├── redirect-to-auth.js
│ ├── product-creator.js
│ └── ensure-billing.ts
├── app_installations.ts
├── package.json
├── gdpr.ts
├── middleware
│ ├── verify-request.ts
│ └── auth.ts
├── index.ts
└── tsconfig.json
├── .dockerignore
├── .vscode
└── extensions.json
├── .npmrc
├── shopify.app.toml
├── Dockerfile
├── package.json
├── .gitignore
├── .all-contributorsrc
├── SECURITY.md
└── README.md
/web/shopify.web.toml:
--------------------------------------------------------------------------------
1 | type="backend"
2 |
3 | [commands]
4 | dev = "npm run dev"
5 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | web/node_modules
2 | web/frontend/node_modules
3 | web/frontend/dist
4 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "shopify.polaris-for-vscode"
4 | ]
5 | }
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 | auto-install-peers=true
3 | shamefully-hoist=true
4 | auto-install-peers=true
5 |
--------------------------------------------------------------------------------
/shopify.app.toml:
--------------------------------------------------------------------------------
1 | # This file stores configurations for your Shopify app.
2 |
3 | scopes = "write_products"
4 |
--------------------------------------------------------------------------------
/web/frontend/components/index.ts:
--------------------------------------------------------------------------------
1 | export { ProductsCard } from "./ProductsCard";
2 | export * from "./providers";
3 |
--------------------------------------------------------------------------------
/web/frontend/shopify.web.toml:
--------------------------------------------------------------------------------
1 | type="frontend"
2 |
3 | [commands]
4 | dev = "npm run dev"
5 | build = "npm run build"
6 |
--------------------------------------------------------------------------------
/web/frontend/prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 4,
4 | "semi": false,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/web/frontend/assets/home-trophy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KaiSpencer/shopify-app-template-node-ts/HEAD/web/frontend/assets/home-trophy.png
--------------------------------------------------------------------------------
/web/frontend/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useAppQuery } from "./useAppQuery";
2 | export { useAuthenticatedFetch } from "./useAuthenticatedFetch";
3 |
--------------------------------------------------------------------------------
/web/frontend/assets/index.js:
--------------------------------------------------------------------------------
1 | export { default as notFoundImage } from "./empty-state.svg";
2 | export { default as trophyImage } from "./home-trophy.png";
3 |
--------------------------------------------------------------------------------
/web/frontend/index.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom";
2 |
3 | import App from "./App";
4 |
5 | ReactDOM.render( , document.getElementById("app"));
6 |
--------------------------------------------------------------------------------
/web/frontend/components/providers/index.js:
--------------------------------------------------------------------------------
1 | export { AppBridgeProvider } from "./AppBridgeProvider";
2 | export { QueryProvider } from "./QueryProvider";
3 | export { PolarisProvider } from "./PolarisProvider";
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18-alpine
2 |
3 | ARG SHOPIFY_API_KEY
4 | ENV SHOPIFY_API_KEY=$SHOPIFY_API_KEY
5 | EXPOSE 8081
6 | WORKDIR /app
7 | COPY web .
8 | RUN npm install
9 | RUN cd frontend && npm install && npm run build
10 | CMD ["npm", "run", "serve"]
11 |
--------------------------------------------------------------------------------
/web/frontend/dev_embed.js:
--------------------------------------------------------------------------------
1 | import RefreshRuntime from "/@react-refresh";
2 |
3 | RefreshRuntime.injectIntoGlobalHook(window);
4 | window.$RefreshReg$ = () => {};
5 | window.$RefreshSig$ = () => (type) => type;
6 | window.__vite_plugin_react_preamble_installed__ = true;
7 |
--------------------------------------------------------------------------------
/web/frontend/assets/empty-state.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/web/environment.ts:
--------------------------------------------------------------------------------
1 | import { envsafe, str } from "envsafe";
2 |
3 | export const environment = envsafe({
4 | NODE_ENV: str({ default: "development" }),
5 | BACKEND_PORT: str({}),
6 | PORT: str({}),
7 | SHOPIFY_API_KEY: str({}),
8 | SHOPIFY_API_SECRET: str({}),
9 | SCOPES: str({}),
10 | HOST: str({}),
11 | });
12 |
--------------------------------------------------------------------------------
/web/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directory
2 | node_modules
3 |
4 | # Ignore Apple macOS Desktop Services Store
5 | .DS_Store
6 |
7 | # Logs
8 | logs
9 | *.log
10 |
11 | # vite build output
12 | dist/
13 |
14 | # Partners can use npm, yarn or pnpm with the CLI.
15 | # We ignore lock files so they don't get a package manager mis-match
16 | # Without this, they may get a warning if using a different package manager to us
17 | yarn.lock
18 | package-lock.json
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fullstack-app-template",
3 | "version": "1.0.0",
4 | "main": "web/index.js",
5 | "license": "UNLICENSED",
6 | "scripts": {
7 | "shopify": "shopify",
8 | "build": "shopify app build",
9 | "dev": "shopify app dev",
10 | "info": "shopify app info",
11 | "generate": "shopify app generate",
12 | "deploy": "shopify app deploy"
13 | },
14 | "dependencies": {
15 | "@shopify/app": "3.28.0",
16 | "@shopify/cli": "3.28.0"
17 | },
18 | "author": "kai"
19 | }
20 |
--------------------------------------------------------------------------------
/web/frontend/components/providers/QueryProvider.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | QueryClient,
3 | QueryClientProvider,
4 | QueryCache,
5 | MutationCache,
6 | } from "react-query";
7 |
8 | /**
9 | * Sets up the QueryClientProvider from react-query.
10 | * @desc See: https://react-query.tanstack.com/reference/QueryClientProvider#_top
11 | */
12 | export function QueryProvider({ children }) {
13 | const client = new QueryClient({
14 | queryCache: new QueryCache(),
15 | mutationCache: new MutationCache(),
16 | });
17 |
18 | return {children} ;
19 | }
20 |
--------------------------------------------------------------------------------
/web/helpers/return-top-level-redirection.js:
--------------------------------------------------------------------------------
1 | export default function returnTopLevelRedirection(req, res, redirectUrl) {
2 | const bearerPresent = req.headers.authorization?.match(/Bearer (.*)/);
3 |
4 | // If the request has a bearer token, the app is currently embedded, and must break out of the iframe to
5 | // re-authenticate
6 | if (bearerPresent) {
7 | res.status(403);
8 | res.header("X-Shopify-API-Request-Failure-Reauthorize", "1");
9 | res.header("X-Shopify-API-Request-Failure-Reauthorize-Url", redirectUrl);
10 | res.end();
11 | } else {
12 | res.redirect(redirectUrl);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/web/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | node: true,
5 | es2021: true,
6 | jest: true,
7 | },
8 | extends: [
9 | "eslint:recommended",
10 | "plugin:react/recommended",
11 | "plugin:@typescript-eslint/recommended",
12 | ],
13 | parser: "@typescript-eslint/parser",
14 | parserOptions: {
15 | ecmaFeatures: {
16 | jsx: true,
17 | },
18 | ecmaVersion: "latest",
19 | sourceType: "module",
20 | },
21 | plugins: ["react", "@typescript-eslint", "plugin:prettier/recommended"],
22 | rules: {
23 | "react/react-in-jsx-scope": 0,
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/web/frontend/pages/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Card, EmptyState, Page } from "@shopify/polaris";
2 | import { notFoundImage } from "../assets";
3 |
4 | export default function NotFound() {
5 | return (
6 |
7 |
8 |
9 |
13 |
14 | Check the URL and try again, or use the search bar to find what
15 | you need.
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Environment Configuration
2 | .env
3 | .env.*
4 |
5 | # Dependency directory
6 | node_modules
7 |
8 | # Test coverage directory
9 | coverage
10 |
11 | # Ignore Apple macOS Desktop Services Store
12 | .DS_Store
13 |
14 | # Logs
15 | logs
16 | *.log
17 |
18 | # ngrok tunnel file
19 | config/tunnel.pid
20 |
21 | # vite build output
22 | dist/
23 |
24 | # extensions build output
25 | extensions/*/build
26 |
27 | # Node library SQLite database
28 | web/database.sqlite
29 |
30 | # Partners can use npm, yarn or pnpm with the CLI.
31 | # We ignore lock files so they don't get a package manager mis-match
32 | # Without this, they may get a warning if using a different package manager to us
33 | yarn.lock
34 | package-lock.json
35 | pnpm-lock.yaml
36 |
--------------------------------------------------------------------------------
/web/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Shopify React Frontend App
2 |
3 | [](LICENSE.md)
4 |
5 | This repository is the frontend for Shopify’s app starter templates. **You probably don’t want to use this repository directly**, but rather through one of the templates and the [Shopify CLI](https://github.com/Shopify/shopify-cli).
6 |
7 | ## Developer resources
8 |
9 | - [Introduction to Shopify apps](https://shopify.dev/apps/getting-started)
10 | - [App authentication](https://shopify.dev/apps/auth)
11 | - [Shopify CLI command reference](https://shopify.dev/apps/tools/cli/app)
12 | - [Shopify API Library documentation](https://github.com/Shopify/shopify-node-api/tree/main/docs)
13 |
14 | ## License
15 |
16 | This repository is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
17 |
--------------------------------------------------------------------------------
/web/app_installations.ts:
--------------------------------------------------------------------------------
1 | import { Shopify } from "@shopify/shopify-api";
2 |
3 | export const AppInstallations = {
4 | includes: async function (shopDomain: string) {
5 | const shopSessions =
6 | (await Shopify.Context.SESSION_STORAGE.findSessionsByShop?.(
7 | shopDomain,
8 | )) || [];
9 |
10 | if (shopSessions.length > 0) {
11 | for (const session of shopSessions) {
12 | if (session.accessToken) return true;
13 | }
14 | }
15 |
16 | return false;
17 | },
18 |
19 | delete: async function (shopDomain: string) {
20 | const shopSessions =
21 | (await Shopify.Context.SESSION_STORAGE.findSessionsByShop?.(
22 | shopDomain,
23 | )) || [];
24 | if (shopSessions.length > 0) {
25 | await Shopify.Context.SESSION_STORAGE.deleteSessions?.(
26 | shopSessions.map((session) => session.id),
27 | );
28 | }
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/web/frontend/pages/ExitIframe.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect } from "@shopify/app-bridge/actions";
2 | import { useAppBridge, Loading } from "@shopify/app-bridge-react";
3 | import { useEffect } from "react";
4 | import { useLocation } from "react-router-dom";
5 |
6 | export default function ExitIframe() {
7 | const app = useAppBridge();
8 | const { search } = useLocation();
9 |
10 | useEffect(() => {
11 | if (!!app && !!search) {
12 | const params = new URLSearchParams(search);
13 | const redirectUri = params.get("redirectUri");
14 | const url = new URL(decodeURIComponent(redirectUri));
15 |
16 | if (url.hostname === location.hostname) {
17 | const redirect = Redirect.create(app);
18 | redirect.dispatch(
19 | Redirect.Action.REMOTE,
20 | decodeURIComponent(redirectUri),
21 | );
22 | }
23 | }
24 | }, [app, search]);
25 |
26 | return ;
27 | }
28 |
--------------------------------------------------------------------------------
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "files": [
3 | "README.md"
4 | ],
5 | "imageSize": 100,
6 | "commit": false,
7 | "commitConvention": "angular",
8 | "contributors": [
9 | {
10 | "login": "KaiSpencer",
11 | "name": "Kai Spencer",
12 | "avatar_url": "https://avatars.githubusercontent.com/u/51139521?v=4",
13 | "profile": "https://github.com/KaiSpencer",
14 | "contributions": [
15 | "code"
16 | ]
17 | },
18 | {
19 | "login": "cyrilchapon",
20 | "name": "Cyril CHAPON",
21 | "avatar_url": "https://avatars.githubusercontent.com/u/10728426?v=4",
22 | "profile": "https://github.com/cyrilchapon",
23 | "contributions": [
24 | "code"
25 | ]
26 | }
27 | ],
28 | "contributorsPerLine": 7,
29 | "skipCi": true,
30 | "repoType": "github",
31 | "repoHost": "https://github.com",
32 | "projectName": "shopify-app-template-node-ts",
33 | "projectOwner": "KaiSpencer"
34 | }
35 |
--------------------------------------------------------------------------------
/web/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "esnext",
4 | "allowJs": true,
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "esModuleInterop": true,
11 | "target": "esnext",
12 | "noImplicitAny": true,
13 | "moduleResolution": "node",
14 | "sourceMap": true,
15 | "skipLibCheck": true,
16 | "allowSyntheticDefaultImports": true,
17 | "outDir": "dist",
18 | "baseUrl": ".",
19 | "noEmit": true,
20 | "paths": {
21 | "*": [
22 | "node_modules/*"
23 | ]
24 | },
25 | "resolveJsonModule": true,
26 | "isolatedModules": true,
27 | "jsx": "react-jsx","types": ["vite/client"]
28 | },
29 | "include": [
30 | "**/*.ts",
31 | "**/*.tsx",
32 | "**/*.js",
33 | "**/*.jsx",
34 | ],
35 | "exclude": [
36 | "node_modules/**"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/web/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/web/frontend/App.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter } from "react-router-dom";
2 | import { NavigationMenu } from "@shopify/app-bridge-react";
3 | import Routes from "./Routes";
4 |
5 | import {
6 | AppBridgeProvider,
7 | QueryProvider,
8 | PolarisProvider,
9 | } from "./components";
10 |
11 | export default function App() {
12 | // Any .tsx or .jsx files in /pages will become a route
13 | // See documentation for for more info
14 | const pages = import.meta.globEager("./pages/**/!(*.test.[jt]sx)*.([jt]sx)");
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shopify-app-template-node",
3 | "private": true,
4 | "license": "UNLICENSED",
5 | "scripts": {
6 | "debug": "node --inspect-brk index.js",
7 | "dev": "cross-env NODE_ENV=development nodemon --exec node --loader ts-node/esm index.ts --ignore ./frontend",
8 | "serve": "cross-env NODE_ENV=production node index.js"
9 | },
10 | "type": "module",
11 | "engines": {
12 | "node": ">=14.13.1"
13 | },
14 | "dependencies": {
15 | "@shopify/shopify-api": "^5.0.0",
16 | "compression": "^1.7.4",
17 | "cookie-parser": "^1.4.6",
18 | "cross-env": "^7.0.3",
19 | "envsafe": "^2.0.3",
20 | "express": "^4.17.3",
21 | "serve-static": "^1.14.1"
22 | },
23 | "devDependencies": {
24 | "@types/compression": "^1.7.2",
25 | "@types/cookie-parser": "^1.4.3",
26 | "@types/express": "^4.17.14",
27 | "@types/node": "^18.8.2",
28 | "@typescript-eslint/parser": "^5.39.0",
29 | "jsonwebtoken": "^8.5.1",
30 | "nodemon": "^2.0.15",
31 | "prettier": "^2.6.2",
32 | "pretty-quick": "^3.1.3",
33 | "ts-node": "^10.9.1",
34 | "ts-node-dev": "^2.0.0",
35 | "typescript": "^4.8.4"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/web/helpers/redirect-to-auth.js:
--------------------------------------------------------------------------------
1 | import { Shopify } from "@shopify/shopify-api";
2 |
3 | export default async function redirectToAuth(req, res, app) {
4 | if (!req.query.shop) {
5 | res.status(500);
6 | return res.send("No shop provided");
7 | }
8 |
9 | if (req.query.embedded === "1") {
10 | return clientSideRedirect(req, res);
11 | }
12 |
13 | return await serverSideRedirect(req, res, app);
14 | }
15 |
16 | function clientSideRedirect(req, res) {
17 | const shop = Shopify.Utils.sanitizeShop(req.query.shop);
18 | const redirectUriParams = new URLSearchParams({
19 | shop,
20 | host: req.query.host,
21 | }).toString();
22 | const queryParams = new URLSearchParams({
23 | ...req.query,
24 | shop,
25 | redirectUri: `https://${Shopify.Context.HOST_NAME}/api/auth?${redirectUriParams}`,
26 | }).toString();
27 |
28 | return res.redirect(`/exitiframe?${queryParams}`);
29 | }
30 |
31 | async function serverSideRedirect(req, res, app) {
32 | const redirectUrl = await Shopify.Auth.beginAuth(
33 | req,
34 | res,
35 | req.query.shop,
36 | "/api/auth/callback",
37 | app.get("use-online-tokens")
38 | );
39 |
40 | return res.redirect(redirectUrl);
41 | }
42 |
--------------------------------------------------------------------------------
/web/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shopify-frontend-template-react",
3 | "private": true,
4 | "license": "UNLICENSED",
5 | "scripts": {
6 | "build": "vite build",
7 | "dev": "vite",
8 | "test": "vitest",
9 | "coverage": "vitest run --coverage",
10 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
11 | "lint:fix": "pnpm run lint -- --fix"
12 | },
13 | "type": "module",
14 | "engines": {
15 | "node": ">= 12.16"
16 | },
17 | "dependencies": {
18 | "@shopify/app-bridge": "^3.1.0",
19 | "@shopify/app-bridge-react": "^3.1.0",
20 | "@shopify/app-bridge-utils": "^3.1.0",
21 | "@shopify/polaris": "^9.11.0",
22 | "@vitejs/plugin-react": "1.2.0",
23 | "react": "^17.0.2",
24 | "react-dom": "^17.0.2",
25 | "react-query": "^3.34.19",
26 | "react-router-dom": "^6.3.0",
27 | "vite": "^2.8.6"
28 | },
29 | "devDependencies": {
30 | "@typescript-eslint/eslint-plugin": "^5.39.0",
31 | "@typescript-eslint/parser": "^5.39.0",
32 | "eslint": "^8.24.0",
33 | "eslint-config-prettier": "^8.5.0",
34 | "eslint-plugin-prettier": "^4.2.1",
35 | "eslint-plugin-react": "^7.31.8",
36 | "history": "^5.3.0",
37 | "jsdom": "^19.0.0",
38 | "prettier": "^2.7.1",
39 | "typescript": "^4.8.4",
40 | "vi-fetch": "^0.6.1"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/web/frontend/pages/pagename.tsx:
--------------------------------------------------------------------------------
1 | import { Card, Page, Layout, TextContainer, Heading } from "@shopify/polaris";
2 | import { TitleBar } from "@shopify/app-bridge-react";
3 |
4 | export default function PageName() {
5 | return (
6 |
7 | console.log("Primary action"),
12 | }}
13 | secondaryActions={[
14 | {
15 | content: "Secondary action",
16 | onAction: () => console.log("Secondary action"),
17 | },
18 | ]}
19 | />
20 |
21 |
22 |
23 | Heading
24 |
25 | Body
26 |
27 |
28 |
29 | Heading
30 |
31 | Body
32 |
33 |
34 |
35 |
36 |
37 | Heading
38 |
39 | Body
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/web/frontend/hooks/useAppQuery.ts:
--------------------------------------------------------------------------------
1 | import { useAuthenticatedFetch } from "./useAuthenticatedFetch";
2 | import { useMemo } from "react";
3 | import { useQuery, UseQueryOptions } from "react-query";
4 |
5 | /**
6 | * A hook for querying your custom app data.
7 | * @desc A thin wrapper around useAuthenticatedFetch and react-query's useQuery.
8 | *
9 | * @param {Object} options - The options for your query. Accepts 3 keys:
10 | *
11 | * 1. url: The URL to query. E.g: /api/widgets/1`
12 | * 2. fetchInit: The init options for fetch. See: https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters
13 | * 3. reactQueryOptions: The options for `useQuery`. See: https://react-query.tanstack.com/reference/useQuery
14 | *
15 | * @returns Return value of useQuery. See: https://react-query.tanstack.com/reference/useQuery.
16 | */
17 | export const useAppQuery = ({
18 | url,
19 | fetchInit = {},
20 | reactQueryOptions,
21 | }: {
22 | url: string;
23 | fetchInit?: RequestInit;
24 | reactQueryOptions: Omit<
25 | UseQueryOptions,
26 | "queryKey" | "queryFn"
27 | >;
28 | }) => {
29 | const authenticatedFetch = useAuthenticatedFetch();
30 | const fetch = useMemo(() => {
31 | return async () => {
32 | const response = await authenticatedFetch(url, fetchInit);
33 | return response.json();
34 | };
35 | }, [url, JSON.stringify(fetchInit)]);
36 |
37 | return useQuery(url, fetch, {
38 | ...reactQueryOptions,
39 | refetchOnWindowFocus: false,
40 | });
41 | };
42 |
--------------------------------------------------------------------------------
/web/frontend/components/providers/PolarisProvider.jsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { AppProvider } from "@shopify/polaris";
3 | import { useNavigate } from "@shopify/app-bridge-react";
4 | import translations from "@shopify/polaris/locales/en.json";
5 | import "@shopify/polaris/build/esm/styles.css";
6 |
7 | function AppBridgeLink({ url, children, external, ...rest }) {
8 | const navigate = useNavigate();
9 | const handleClick = useCallback(() => {
10 | navigate(url);
11 | }, [url]);
12 |
13 | const IS_EXTERNAL_LINK_REGEX = /^(?:[a-z][a-z\d+.-]*:|\/\/)/;
14 |
15 | if (external || IS_EXTERNAL_LINK_REGEX.test(url)) {
16 | return (
17 |
18 | {children}
19 |
20 | );
21 | }
22 |
23 | return (
24 |
25 | {children}
26 |
27 | );
28 | }
29 |
30 | /**
31 | * Sets up the AppProvider from Polaris.
32 | * @desc PolarisProvider passes a custom link component to Polaris.
33 | * The Link component handles navigation within an embedded app.
34 | * Prefer using this vs any other method such as an anchor.
35 | * Use it by importing Link from Polaris, e.g:
36 | *
37 | * ```
38 | * import {Link} from '@shopify/polaris'
39 | *
40 | * function MyComponent() {
41 | * return (
42 | * Tab 2
43 | * )
44 | * }
45 | * ```
46 | *
47 | * PolarisProvider also passes translations to Polaris.
48 | *
49 | */
50 | export function PolarisProvider({ children }) {
51 | return (
52 |
53 | {children}
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/web/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { dirname } from "path";
3 | import { fileURLToPath } from "url";
4 | import https from "https";
5 | import react from "@vitejs/plugin-react";
6 |
7 | if (
8 | process.env.npm_lifecycle_event === "build" &&
9 | !process.env.CI &&
10 | !process.env.SHOPIFY_API_KEY
11 | ) {
12 | console.warn(
13 | "\nBuilding the frontend app without an API key. The frontend build will not run without an API key. Set the SHOPIFY_API_KEY environment variable when running the build command.\n"
14 | );
15 | }
16 |
17 | const proxyOptions = {
18 | target: `http://127.0.0.1:${process.env.BACKEND_PORT}`,
19 | changeOrigin: false,
20 | secure: true,
21 | ws: false,
22 | };
23 |
24 | const host = process.env.HOST
25 | ? process.env.HOST.replace(/https?:\/\//, "")
26 | : "localhost";
27 |
28 | let hmrConfig;
29 | if (host === "localhost") {
30 | hmrConfig = {
31 | protocol: "ws",
32 | host: "localhost",
33 | port: 64999,
34 | clientPort: 64999,
35 | };
36 | } else {
37 | hmrConfig = {
38 | protocol: "wss",
39 | host: host,
40 | port: process.env.FRONTEND_PORT,
41 | clientPort: 443,
42 | };
43 | }
44 |
45 | export default defineConfig({
46 | root: dirname(fileURLToPath(import.meta.url)),
47 | plugins: [react()],
48 | define: {
49 | "process.env.SHOPIFY_API_KEY": JSON.stringify(process.env.SHOPIFY_API_KEY),
50 | },
51 | resolve: {
52 | preserveSymlinks: true,
53 | },
54 | server: {
55 | host: "localhost",
56 | port: process.env.FRONTEND_PORT,
57 | hmr: hmrConfig,
58 | proxy: {
59 | "^/(\\?.*)?$": proxyOptions,
60 | "^/api(/|(\\?.*)?$)": proxyOptions,
61 | },
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/web/frontend/hooks/useAuthenticatedFetch.ts:
--------------------------------------------------------------------------------
1 | import { authenticatedFetch } from "@shopify/app-bridge-utils";
2 | import { useAppBridge } from "@shopify/app-bridge-react";
3 | import { Redirect } from "@shopify/app-bridge/actions";
4 | import { ClientApplication, AppBridgeState } from "@shopify/app-bridge";
5 |
6 | /**
7 | * A hook that returns an auth-aware fetch function.
8 | * @desc The returned fetch function that matches the browser's fetch API
9 | * See: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
10 | * It will provide the following functionality:
11 | *
12 | * 1. Add a `X-Shopify-Access-Token` header to the request.
13 | * 2. Check response for `X-Shopify-API-Request-Failure-Reauthorize` header.
14 | * 3. Redirect the user to the reauthorization URL if the header is present.
15 | *
16 | * @returns {Function} fetch function
17 | */
18 | export function useAuthenticatedFetch() {
19 | const app = useAppBridge();
20 | const fetchFunction = authenticatedFetch(app);
21 |
22 | return async (uri: RequestInfo, options?: RequestInit) => {
23 | const response = await fetchFunction(uri, options);
24 | checkHeadersForReauthorization(response.headers, app);
25 | return response;
26 | };
27 | }
28 |
29 | function checkHeadersForReauthorization(
30 | headers: Headers,
31 | app: ClientApplication,
32 | ) {
33 | if (headers.get("X-Shopify-API-Request-Failure-Reauthorize") === "1") {
34 | const authUrlHeader =
35 | headers.get("X-Shopify-API-Request-Failure-Reauthorize-Url") ||
36 | `/api/auth`;
37 |
38 | const redirect = Redirect.create(app);
39 | redirect.dispatch(
40 | Redirect.Action.REMOTE,
41 | authUrlHeader.startsWith("/")
42 | ? `https://${window.location.host}${authUrlHeader}`
43 | : authUrlHeader,
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/web/frontend/components/ProductsCard.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import {
3 | Card,
4 | Heading,
5 | TextContainer,
6 | DisplayText,
7 | TextStyle,
8 | } from "@shopify/polaris";
9 | import { Toast } from "@shopify/app-bridge-react";
10 | import { useAppQuery, useAuthenticatedFetch } from "../hooks";
11 |
12 | export function ProductsCard() {
13 | const emptyToastProps: { content: string | null; error?: boolean } = {
14 | content: null,
15 | };
16 | const [isLoading, setIsLoading] = useState(true);
17 | const [toastProps, setToastProps] = useState(emptyToastProps);
18 | const fetch = useAuthenticatedFetch();
19 |
20 | const {
21 | data,
22 | refetch: refetchProductCount,
23 | isLoading: isLoadingCount,
24 | isRefetching: isRefetchingCount,
25 | } = useAppQuery({
26 | url: "/api/products/count",
27 | reactQueryOptions: {
28 | onSuccess: () => {
29 | setIsLoading(false);
30 | },
31 | },
32 | });
33 |
34 | const toastMarkup = toastProps.content && !isRefetchingCount && (
35 | setToastProps(emptyToastProps)} />
36 | );
37 |
38 | const handlePopulate = async () => {
39 | setIsLoading(true);
40 | const response = await fetch("/api/products/create");
41 |
42 | if (response.ok) {
43 | await refetchProductCount();
44 | setToastProps({ content: "5 products created!" });
45 | } else {
46 | setIsLoading(false);
47 | setToastProps({
48 | content: "There was an error creating products",
49 | error: true,
50 | });
51 | }
52 | };
53 |
54 | return (
55 | <>
56 | {toastMarkup}
57 |
66 |
67 |
68 | Sample products are created with a default title and price. You can
69 | remove them at any time.
70 |
71 |
72 | TOTAL PRODUCTS
73 |
74 |
75 | {isLoadingCount ? "-" : data.count}
76 |
77 |
78 |
79 |
80 |
81 | >
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/web/frontend/Routes.tsx:
--------------------------------------------------------------------------------
1 | import { Routes as ReactRouterRoutes, Route } from "react-router-dom";
2 |
3 | /**
4 | * File-based routing.
5 | * @desc File-based routing that uses React Router under the hood.
6 | * To create a new route create a new .jsx file in `/pages` with a default export.
7 | *
8 | * Some examples:
9 | * * `/pages/index.jsx` matches `/`
10 | * * `/pages/blog/[id].jsx` matches `/blog/123`
11 | * * `/pages/[...catchAll].jsx` matches any URL not explicitly matched
12 | *
13 | * @param {object} pages value of import.meta.globEager(). See https://vitejs.dev/guide/features.html#glob-import
14 | *
15 | * @return {Routes} ` ` from React Router, with a ` ` for each file in `pages`
16 | */
17 | export default function Routes({ pages }: { pages: Record }) {
18 | const routes = useRoutes(pages);
19 | const routeComponents = routes.map(({ path, component: Component }) => (
20 | } />
21 | ));
22 |
23 | const NotFound = routes.find(({ path }) => path === "/notFound").component;
24 |
25 | return (
26 |
27 | {routeComponents}
28 | } />
29 |
30 | );
31 | }
32 |
33 | function useRoutes(pages: Record) {
34 | const routes = Object.keys(pages)
35 | .map((key) => {
36 | let path = key
37 | .replace("./pages", "")
38 | .replace(/\.(t|j)sx?$/, "")
39 | /**
40 | * Replace /index with /
41 | */
42 | .replace(/\/index$/i, "/")
43 | /**
44 | * Only lowercase the first letter. This allows the developer to use camelCase
45 | * dynamic paths while ensuring their standard routes are normalized to lowercase.
46 | */
47 | .replace(/\b[A-Z]/, (firstLetter) => firstLetter.toLowerCase())
48 | /**
49 | * Convert /[handle].jsx and /[...handle].jsx to /:handle.jsx for react-router-dom
50 | */
51 | .replace(/\[(?:[.]{3})?(\w+?)\]/g, (_match, param) => `:${param}`);
52 |
53 | if (path.endsWith("/") && path !== "/") {
54 | path = path.substring(0, path.length - 1);
55 | }
56 |
57 | if (!pages[key].default) {
58 | console.warn(`${key} doesn't export a default React component`);
59 | }
60 |
61 | return {
62 | path,
63 | component: pages[key].default,
64 | };
65 | })
66 | .filter((route) => route.component);
67 |
68 | return routes;
69 | }
70 |
--------------------------------------------------------------------------------
/web/gdpr.ts:
--------------------------------------------------------------------------------
1 | import { Shopify, WebhookRegistryEntry } from "@shopify/shopify-api";
2 |
3 | export function setupGDPRWebHooks(path: WebhookRegistryEntry["path"]) {
4 | /**
5 | * Customers can request their data from a store owner. When this happens,
6 | * Shopify invokes this webhook.
7 | *
8 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#customers-data_request
9 | */
10 | Shopify.Webhooks.Registry.addHandler("CUSTOMERS_DATA_REQUEST", {
11 | path,
12 | webhookHandler: async (topic, shop, body) => {
13 | const payload = JSON.parse(body);
14 | // Payload has the following shape:
15 | // {
16 | // "shop_id": 954889,
17 | // "shop_domain": "{shop}.myshopify.com",
18 | // "orders_requested": [
19 | // 299938,
20 | // 280263,
21 | // 220458
22 | // ],
23 | // "customer": {
24 | // "id": 191167,
25 | // "email": "john@example.com",
26 | // "phone": "555-625-1199"
27 | // },
28 | // "data_request": {
29 | // "id": 9999
30 | // }
31 | // }
32 | },
33 | });
34 |
35 | /**
36 | * Store owners can request that data is deleted on behalf of a customer. When
37 | * this happens, Shopify invokes this webhook.
38 | *
39 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#customers-redact
40 | */
41 | Shopify.Webhooks.Registry.addHandler("CUSTOMERS_REDACT", {
42 | path,
43 | webhookHandler: async (topic, shop, body) => {
44 | const payload = JSON.parse(body);
45 | // Payload has the following shape:
46 | // {
47 | // "shop_id": 954889,
48 | // "shop_domain": "{shop}.myshopify.com",
49 | // "customer": {
50 | // "id": 191167,
51 | // "email": "john@example.com",
52 | // "phone": "555-625-1199"
53 | // },
54 | // "orders_to_redact": [
55 | // 299938,
56 | // 280263,
57 | // 220458
58 | // ]
59 | // }
60 | },
61 | });
62 |
63 | /**
64 | * 48 hours after a store owner uninstalls your app, Shopify invokes this
65 | * webhook.
66 | *
67 | * https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks#shop-redact
68 | */
69 | Shopify.Webhooks.Registry.addHandler("SHOP_REDACT", {
70 | path,
71 | webhookHandler: async (topic, shop, body) => {
72 | const payload = JSON.parse(body);
73 | // Payload has the following shape:
74 | // {
75 | // "shop_id": 954889,
76 | // "shop_domain": "{shop}.myshopify.com"
77 | // }
78 | },
79 | });
80 | }
81 |
--------------------------------------------------------------------------------
/web/helpers/product-creator.js:
--------------------------------------------------------------------------------
1 | import { Shopify } from "@shopify/shopify-api";
2 |
3 | const ADJECTIVES = [
4 | "autumn",
5 | "hidden",
6 | "bitter",
7 | "misty",
8 | "silent",
9 | "empty",
10 | "dry",
11 | "dark",
12 | "summer",
13 | "icy",
14 | "delicate",
15 | "quiet",
16 | "white",
17 | "cool",
18 | "spring",
19 | "winter",
20 | "patient",
21 | "twilight",
22 | "dawn",
23 | "crimson",
24 | "wispy",
25 | "weathered",
26 | "blue",
27 | "billowing",
28 | "broken",
29 | "cold",
30 | "damp",
31 | "falling",
32 | "frosty",
33 | "green",
34 | "long",
35 | ]
36 |
37 | const NOUNS = [
38 | "waterfall",
39 | "river",
40 | "breeze",
41 | "moon",
42 | "rain",
43 | "wind",
44 | "sea",
45 | "morning",
46 | "snow",
47 | "lake",
48 | "sunset",
49 | "pine",
50 | "shadow",
51 | "leaf",
52 | "dawn",
53 | "glitter",
54 | "forest",
55 | "hill",
56 | "cloud",
57 | "meadow",
58 | "sun",
59 | "glade",
60 | "bird",
61 | "brook",
62 | "butterfly",
63 | "bush",
64 | "dew",
65 | "dust",
66 | "field",
67 | "fire",
68 | "flower",
69 | ]
70 |
71 | export const DEFAULT_PRODUCTS_COUNT = 5;
72 | const CREATE_PRODUCTS_MUTATION = `
73 | mutation populateProduct($input: ProductInput!) {
74 | productCreate(input: $input) {
75 | product {
76 | id
77 | }
78 | }
79 | }
80 | `
81 |
82 | export default async function productCreator(session, count = DEFAULT_PRODUCTS_COUNT) {
83 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken);
84 |
85 | try {
86 | for (let i = 0; i < count; i++) {
87 | await client.query({
88 | data: {
89 | query: CREATE_PRODUCTS_MUTATION,
90 | variables: {
91 | input: {
92 | title: `${randomTitle()}`,
93 | variants: [{ price: randomPrice() }],
94 | },
95 | },
96 | },
97 | });
98 | }
99 | } catch (error) {
100 | if (error instanceof ShopifyErrors.GraphqlQueryError) {
101 | throw new Error(`${error.message}\n${JSON.stringify(error.response, null, 2)}`);
102 | } else {
103 | throw error;
104 | }
105 | }
106 | }
107 |
108 | function randomTitle() {
109 | const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
110 | const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
111 | return `${adjective} ${noun}`;
112 | }
113 |
114 | function randomPrice() {
115 | return Math.round((Math.random() * 10 + Number.EPSILON) * 100) / 100;
116 | }
117 |
--------------------------------------------------------------------------------
/web/frontend/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | Page,
4 | Layout,
5 | TextContainer,
6 | Image,
7 | Stack,
8 | Link,
9 | Heading,
10 | } from "@shopify/polaris";
11 | import { TitleBar } from "@shopify/app-bridge-react";
12 |
13 | import { trophyImage } from "../assets";
14 |
15 | import { ProductsCard } from "../components";
16 |
17 | export default function HomePage() {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
30 |
31 |
32 | Nice work on building a Shopify app 🎉
33 |
34 | Your app is ready to explore! It contains everything you
35 | need to get started including the{" "}
36 |
37 | Polaris design system
38 |
39 | ,{" "}
40 |
41 | Shopify Admin API
42 |
43 | , and{" "}
44 |
48 | App Bridge
49 | {" "}
50 | UI library and components.
51 |
52 |
53 | Ready to go? Start populating your app with some sample
54 | products to view and test in your store.{" "}
55 |
56 |
57 | Learn more about building out your app in{" "}
58 |
62 | this Shopify tutorial
63 | {" "}
64 | 📚{" "}
65 |
66 |
67 |
68 |
69 |
70 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/web/middleware/verify-request.ts:
--------------------------------------------------------------------------------
1 | import { Shopify } from "@shopify/shopify-api";
2 | import { Application } from "express";
3 | import ensureBilling, {
4 | ShopifyBillingError,
5 | } from "../helpers/ensure-billing.js";
6 | import redirectToAuth from "../helpers/redirect-to-auth.js";
7 |
8 | import returnTopLevelRedirection from "../helpers/return-top-level-redirection.js";
9 | import { BillingSettingsType } from "../index.js";
10 |
11 | const TEST_GRAPHQL_QUERY = `
12 | {
13 | shop {
14 | name
15 | }
16 | }`;
17 |
18 | export default function verifyRequest(
19 | app: Application,
20 | { billing = { required: false } }: { billing: BillingSettingsType },
21 | ) {
22 | return async (req: any, res: any, next: () => any) => {
23 | const session = await Shopify.Utils.loadCurrentSession(
24 | req,
25 | res,
26 | app.get("use-online-tokens"),
27 | );
28 |
29 | let shop = Shopify.Utils.sanitizeShop(req.query.shop);
30 | if (session && shop && session.shop !== shop) {
31 | // The current request is for a different shop. Redirect gracefully.
32 | return redirectToAuth(req, res, app);
33 | }
34 |
35 | if (session?.isActive()) {
36 | try {
37 | if (billing.required) {
38 | // The request to check billing status serves to validate that the access token is still valid.
39 | const [hasPayment, confirmationUrl] = await ensureBilling(
40 | session,
41 | billing,
42 | );
43 |
44 | if (!hasPayment) {
45 | returnTopLevelRedirection(req, res, confirmationUrl);
46 | return;
47 | }
48 | } else {
49 | // Make a request to ensure the access token is still valid. Otherwise, re-authenticate the user.
50 | const client = new Shopify.Clients.Graphql(
51 | session.shop,
52 | session.accessToken,
53 | );
54 | await client.query({ data: TEST_GRAPHQL_QUERY });
55 | }
56 | return next();
57 | } catch (e) {
58 | if (
59 | e instanceof Shopify.Errors.HttpResponseError &&
60 | e.response.code === 401
61 | ) {
62 | // Re-authenticate if we get a 401 response
63 | } else if (e instanceof ShopifyBillingError) {
64 | console.error(e.message, (e as ShopifyBillingError).errorData[0]);
65 | res.status(500).end();
66 | return;
67 | } else {
68 | throw e;
69 | }
70 | }
71 | }
72 |
73 | const bearerPresent = req.headers.authorization?.match(/Bearer (.*)/);
74 | if (bearerPresent) {
75 | if (!shop) {
76 | if (session) {
77 | shop = session.shop;
78 | } else if (Shopify.Context.IS_EMBEDDED_APP) {
79 | if (bearerPresent) {
80 | const payload = Shopify.Utils.decodeSessionToken(bearerPresent[1]);
81 | shop = payload.dest.replace("https://", "");
82 | }
83 | }
84 | }
85 | }
86 | if (!shop) throw new Error("No shop query parameter provided");
87 | returnTopLevelRedirection(
88 | req,
89 | res,
90 | `/api/auth?shop=${encodeURIComponent(shop)}`,
91 | );
92 | };
93 | }
94 |
--------------------------------------------------------------------------------
/web/frontend/components/providers/AppBridgeProvider.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from "react";
2 | import { useLocation, useNavigate } from "react-router-dom";
3 | import { Provider } from "@shopify/app-bridge-react";
4 | import { Banner, Layout, Page } from "@shopify/polaris";
5 |
6 | /**
7 | * A component to configure App Bridge.
8 | * @desc A thin wrapper around AppBridgeProvider that provides the following capabilities:
9 | *
10 | * 1. Ensures that navigating inside the app updates the host URL.
11 | * 2. Configures the App Bridge Provider, which unlocks functionality provided by the host.
12 | *
13 | * See: https://shopify.dev/apps/tools/app-bridge/react-components
14 | */
15 | export function AppBridgeProvider({ children }) {
16 | const location = useLocation();
17 | const navigate = useNavigate();
18 | const history = useMemo(
19 | () => ({
20 | replace: (path) => {
21 | navigate(path, { replace: true });
22 | },
23 | }),
24 | [navigate]
25 | );
26 |
27 | const routerConfig = useMemo(
28 | () => ({ history, location }),
29 | [history, location]
30 | );
31 |
32 | // The host may be present initially, but later removed by navigation.
33 | // By caching this in state, we ensure that the host is never lost.
34 | // During the lifecycle of an app, these values should never be updated anyway.
35 | // Using state in this way is preferable to useMemo.
36 | // See: https://stackoverflow.com/questions/60482318/version-of-usememo-for-caching-a-value-that-will-never-change
37 | const [appBridgeConfig] = useState(() => {
38 | const host =
39 | new URLSearchParams(location.search).get("host") ||
40 | window.__SHOPIFY_DEV_HOST;
41 |
42 | window.__SHOPIFY_DEV_HOST = host;
43 |
44 | return {
45 | host,
46 | apiKey: process.env.SHOPIFY_API_KEY,
47 | forceRedirect: true,
48 | };
49 | });
50 |
51 | if (!process.env.SHOPIFY_API_KEY || !appBridgeConfig.host) {
52 | const bannerProps = !process.env.SHOPIFY_API_KEY
53 | ? {
54 | title: "Missing Shopify API Key",
55 | children: (
56 | <>
57 | Your app is running without the SHOPIFY_API_KEY environment
58 | variable. Please ensure that it is set when running or building
59 | your React app.
60 | >
61 | ),
62 | }
63 | : {
64 | title: "Missing host query argument",
65 | children: (
66 | <>
67 | Your app can only load if the URL has a host argument.
68 | Please ensure that it is set, or access your app using the
69 | Partners Dashboard Test your app feature
70 | >
71 | ),
72 | };
73 |
74 | return (
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
87 | return (
88 |
89 | {children}
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/web/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import { AuthQuery, BillingSettings, Shopify } from "@shopify/shopify-api";
2 | import { gdprTopics } from "@shopify/shopify-api/dist/webhooks/registry.js";
3 | import { Application } from "express";
4 | import ensureBilling from "../helpers/ensure-billing.js";
5 | import redirectToAuth from "../helpers/redirect-to-auth.js";
6 | import { BillingSettingsType } from "../index.js";
7 |
8 | export default function applyAuthMiddleware(
9 | app: Application,
10 | {
11 | billing,
12 | }: {
13 | billing: BillingSettingsType;
14 | },
15 | ) {
16 | app.get("/api/auth", async (req, res) => {
17 | return redirectToAuth(req, res, app);
18 | });
19 |
20 | app.get(
21 | "/api/auth/callback",
22 | async (req, res) => {
23 | try {
24 | const session = await Shopify.Auth.validateAuthCallback(
25 | req,
26 | res,
27 | req.query,
28 | );
29 | if (!session.accessToken) {
30 | throw new Error("No access token found in session");
31 | }
32 |
33 | const responses = await Shopify.Webhooks.Registry.registerAll({
34 | shop: session.shop,
35 | accessToken: session.accessToken,
36 | });
37 |
38 | Object.entries(responses).map(([topic, response]) => {
39 | const res = response as {
40 | success: boolean;
41 | result: { errors?: any[]; data?: any };
42 | };
43 | // The response from registerAll will include errors for the GDPR topics. These can be safely ignored.
44 | // To register the GDPR topics, please set the appropriate webhook endpoint in the
45 | // 'GDPR mandatory webhooks' section of 'App setup' in the Partners Dashboard.
46 | if (!res.success && !gdprTopics.includes(topic)) {
47 | if (res.result.errors) {
48 | console.log(
49 | `Failed to register ${topic} webhook: ${res.result.errors[0].message}`,
50 | );
51 | } else {
52 | console.log(
53 | `Failed to register ${topic} webhook: ${JSON.stringify(
54 | res.result.data,
55 | undefined,
56 | 2,
57 | )}`,
58 | );
59 | }
60 | }
61 | });
62 |
63 | // If billing is required, check if the store needs to be charged right away to minimize the number of redirects.
64 | if (billing.required) {
65 | const [hasPayment, confirmationUrl] = await ensureBilling(
66 | session,
67 | billing,
68 | );
69 |
70 | if (!hasPayment) {
71 | return res.redirect(confirmationUrl);
72 | }
73 | }
74 | const reqHost = req.query.host;
75 | if (!reqHost) throw new Error("No host found in query");
76 | const host = Shopify.Utils.sanitizeHost(reqHost);
77 | if (!host) throw new Error("No host sanitized from query");
78 | const redirectUrl = Shopify.Context.IS_EMBEDDED_APP
79 | ? Shopify.Utils.getEmbeddedAppUrl(req)
80 | : `/?shop=${session.shop}&host=${encodeURIComponent(host)}`;
81 |
82 | res.redirect(redirectUrl);
83 | } catch (e: any) {
84 | console.warn(e);
85 | switch (true) {
86 | case e instanceof Shopify.Errors.InvalidOAuthError:
87 | res.status(400);
88 | res.send(e.message);
89 | break;
90 | case e instanceof Shopify.Errors.CookieNotFound:
91 | case e instanceof Shopify.Errors.SessionNotFound:
92 | // This is likely because the OAuth session cookie expired before the merchant approved the request
93 | return redirectToAuth(req, res, app);
94 | break;
95 | default:
96 | res.status(500);
97 | res.send(e.message);
98 | break;
99 | }
100 | }
101 | },
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported versions
4 |
5 | ### New features
6 |
7 | New features will only be added to the master branch and will not be made available in point releases.
8 |
9 | ### Bug fixes
10 |
11 | Only the latest release series will receive bug fixes. When enough bugs are fixed and its deemed worthy to release a new gem, this is the branch it happens from.
12 |
13 | ### Security issues
14 |
15 | Only the latest release series will receive patches and new versions in case of a security issue.
16 |
17 | ### Severe security issues
18 |
19 | For severe security issues we will provide new versions as above, and also the last major release series will receive patches and new versions. The classification of the security issue is judged by the core team.
20 |
21 | ### Unsupported Release Series
22 |
23 | When a release series is no longer supported, it's your own responsibility to deal with bugs and security issues. If you are not comfortable maintaining your own versions, you should upgrade to a supported version.
24 |
25 | ## Reporting a bug
26 |
27 | All security bugs in shopify repositories should be reported to [our hackerone program](https://hackerone.com/shopify)
28 | Shopify's whitehat program is our way to reward security researchers for finding serious security vulnerabilities in the In Scope properties listed at the bottom of this page, including our core application (all functionality associated with a Shopify store, particularly your-store.myshopify.com/admin) and certain ancillary applications.
29 |
30 | ## Disclosure Policy
31 |
32 | We look forward to working with all security researchers and strive to be respectful, always assume the best and treat others as peers. We expect the same in return from all participants. To achieve this, our team strives to:
33 |
34 | - Reply to all reports within one business day and triage within two business days (if applicable)
35 | - Be as transparent as possible, answering all inquires about our report decisions and adding hackers to duplicate HackerOne reports
36 | - Award bounties within a week of resolution (excluding extenuating circumstances)
37 | - Only close reports as N/A when the issue reported is included in Known Issues, Ineligible Vulnerabilities Types or lacks evidence of a vulnerability
38 |
39 | **The following rules must be followed in order for any rewards to be paid:**
40 |
41 | - You may only test against shops you have created which include your HackerOne YOURHANDLE @ wearehackerone.com registered email address.
42 | - You must not attempt to gain access to, or interact with, any shops other than those created by you.
43 | - The use of commercial scanners is prohibited (e.g., Nessus).
44 | - Rules for reporting must be followed.
45 | - Do not disclose any issues publicly before they have been resolved.
46 | - Shopify reserves the right to modify the rules for this program or deem any submissions invalid at any time. Shopify may cancel the whitehat program without notice at any time.
47 | - Contacting Shopify Support over chat, email or phone about your HackerOne report is not allowed. We may disqualify you from receiving a reward, or from participating in the program altogether.
48 | - You are not an employee of Shopify; employees should report bugs to the internal bug bounty program.
49 | - You hereby represent, warrant and covenant that any content you submit to Shopify is an original work of authorship and that you are legally entitled to grant the rights and privileges conveyed by these terms. You further represent, warrant and covenant that the consent of no other person or entity is or will be necessary for Shopify to use the submitted content.
50 | - By submitting content to Shopify, you irrevocably waive all moral rights which you may have in the content.
51 | - All content submitted by you to Shopify under this program is licensed under the MIT License.
52 | - You must report any discovered vulnerability to Shopify as soon as you have validated the vulnerability.
53 | - Failure to follow any of the foregoing rules will disqualify you from participating in this program.
54 |
55 | \*\* Please see our [Hackerone Profile](https://hackerone.com/shopify) for full details
56 |
57 | ## Receiving Security Updates
58 |
59 | To recieve all general updates to vulnerabilities, please subscribe to our hackerone [Hacktivity](https://hackerone.com/shopify/hacktivity)
60 |
--------------------------------------------------------------------------------
/web/index.ts:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { join } from "path";
3 | import { readFileSync } from "fs";
4 | import express from "express";
5 | import cookieParser from "cookie-parser";
6 | import {
7 | Shopify,
8 | LATEST_API_VERSION,
9 | BillingSettings,
10 | } from "@shopify/shopify-api";
11 |
12 | import applyAuthMiddleware from "./middleware/auth.js";
13 | import verifyRequest from "./middleware/verify-request.js";
14 | import { setupGDPRWebHooks } from "./gdpr.js";
15 | import productCreator from "./helpers/product-creator.js";
16 | import redirectToAuth from "./helpers/redirect-to-auth.js";
17 | import { AppInstallations } from "./app_installations.js";
18 | import { environment } from "./environment.js";
19 |
20 | const USE_ONLINE_TOKENS = false;
21 |
22 | const PORT = parseInt(environment.BACKEND_PORT || environment.PORT, 10);
23 |
24 | // TODO: There should be provided by env vars
25 | const DEV_INDEX_PATH = `${process.cwd()}/frontend/`;
26 | const PROD_INDEX_PATH = `${process.cwd()}/frontend/dist/`;
27 |
28 | const DB_PATH = `${process.cwd()}/database.sqlite`;
29 |
30 | Shopify.Context.initialize({
31 | API_KEY: environment.SHOPIFY_API_KEY,
32 | API_SECRET_KEY: environment.SHOPIFY_API_SECRET,
33 | SCOPES: environment.SCOPES.split(","),
34 | HOST_NAME: environment.HOST.replace(/https?:\/\//, ""),
35 | HOST_SCHEME: environment.HOST.split("://")[0],
36 | API_VERSION: LATEST_API_VERSION,
37 | IS_EMBEDDED_APP: true,
38 | // This should be replaced with your preferred storage strategy
39 | SESSION_STORAGE: new Shopify.Session.SQLiteSessionStorage(DB_PATH),
40 | });
41 |
42 | Shopify.Webhooks.Registry.addHandler("APP_UNINSTALLED", {
43 | path: "/api/webhooks",
44 | webhookHandler: async (_topic, shop, _body) => {
45 | await AppInstallations.delete(shop);
46 | },
47 | });
48 |
49 | // The transactions with Shopify will always be marked as test transactions, unless NODE_ENV is production.
50 | // See the ensureBilling helper to learn more about billing in this template.
51 | export type BillingSettingsType = T extends true
52 | ? BillingSettings & { required: T }
53 | : { required: T };
54 |
55 | const BILLING_SETTINGS: BillingSettingsType = {
56 | required: false,
57 | // This is an example configuration that would do a one-time charge for $5 (only USD is currently supported)
58 | // chargeName: "My Shopify One-Time Charge",
59 | // amount: 5.0,
60 | // currencyCode: "USD",
61 | // interval: BillingInterval.OneTime,
62 | };
63 |
64 | // This sets up the mandatory GDPR webhooks. You’ll need to fill in the endpoint
65 | // in the “GDPR mandatory webhooks” section in the “App setup” tab, and customize
66 | // the code when you store customer data.
67 | //
68 | // More details can be found on shopify.dev:
69 | // https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks
70 | setupGDPRWebHooks("/api/webhooks");
71 |
72 | // export for test use only
73 | export async function createServer(
74 | root = process.cwd(),
75 | isProd = environment.NODE_ENV === "production",
76 | billingSettings = BILLING_SETTINGS,
77 | ) {
78 | const app = express();
79 |
80 | app.set("use-online-tokens", USE_ONLINE_TOKENS);
81 | app.use(cookieParser(Shopify.Context.API_SECRET_KEY));
82 |
83 | applyAuthMiddleware(app, {
84 | billing: billingSettings,
85 | });
86 |
87 | // Do not call app.use(express.json()) before processing webhooks with
88 | // Shopify.Webhooks.Registry.process().
89 | // See https://github.com/Shopify/shopify-api-node/blob/main/docs/usage/webhooks.md#note-regarding-use-of-body-parsers
90 | // for more details.
91 | app.post("/api/webhooks", async (req, res) => {
92 | try {
93 | await Shopify.Webhooks.Registry.process(req, res);
94 | console.log(`Webhook processed, returned status code 200`);
95 | } catch (e: any) {
96 | console.log(`Failed to process webhook: ${e.message}`);
97 | if (!res.headersSent) {
98 | res.status(500).send(e.message);
99 | }
100 | }
101 | });
102 |
103 | // All endpoints after this point will require an active session
104 | app.use(
105 | "/api/*",
106 | verifyRequest(app, {
107 | billing: billingSettings,
108 | }),
109 | );
110 |
111 | app.get("/api/products/count", async (req, res) => {
112 | const session = await Shopify.Utils.loadCurrentSession(
113 | req,
114 | res,
115 | app.get("use-online-tokens"),
116 | );
117 | const { Product } = await import(
118 | `@shopify/shopify-api/dist/rest-resources/${Shopify.Context.API_VERSION}/index.js`
119 | );
120 |
121 | const countData = await Product.count({ session });
122 | res.status(200).send(countData);
123 | });
124 |
125 | app.get("/api/products/create", async (req, res) => {
126 | const session = await Shopify.Utils.loadCurrentSession(
127 | req,
128 | res,
129 | app.get("use-online-tokens"),
130 | );
131 | let status = 200;
132 | let error = null;
133 |
134 | try {
135 | await productCreator(session);
136 | } catch (e: any) {
137 | console.log(`Failed to process products/create: ${e.message}`);
138 | status = 500;
139 | error = e.message;
140 | }
141 | res.status(status).send({ success: status === 200, error });
142 | });
143 |
144 | // All endpoints after this point will have access to a request.body
145 | // attribute, as a result of the express.json() middleware
146 | app.use(express.json());
147 |
148 | app.use((req, res, next) => {
149 | const shopQuery = req.query.shop;
150 | if (!shopQuery) throw new Error("No shop query parameter provided");
151 | if (typeof shopQuery !== "string")
152 | throw new Error("Invalid shop query parameter provided");
153 | const shop = Shopify.Utils.sanitizeShop(shopQuery);
154 | if (Shopify.Context.IS_EMBEDDED_APP && shop) {
155 | res.setHeader(
156 | "Content-Security-Policy",
157 | `frame-ancestors https://${encodeURIComponent(
158 | shop,
159 | )} https://admin.shopify.com;`,
160 | );
161 | } else {
162 | res.setHeader("Content-Security-Policy", `frame-ancestors 'none';`);
163 | }
164 | next();
165 | });
166 |
167 | if (isProd) {
168 | const compression = await import("compression").then(
169 | ({ default: fn }) => fn,
170 | );
171 | const serveStatic = await import("serve-static").then(
172 | ({ default: fn }) => fn,
173 | );
174 | app.use(compression());
175 | app.use(serveStatic(PROD_INDEX_PATH, { index: false }));
176 | }
177 |
178 | app.use("/*", async (req, res, next) => {
179 | if (typeof req.query.shop !== "string") {
180 | res.status(500);
181 | return res.send("No shop provided");
182 | }
183 |
184 | const shop = Shopify.Utils.sanitizeShop(req.query.shop);
185 | if (!shop) throw new Error("Invalid shop provided");
186 | const appInstalled = await AppInstallations.includes(shop);
187 |
188 | if (!appInstalled && !req.originalUrl.match(/^\/exitiframe/i)) {
189 | return redirectToAuth(req, res, app);
190 | }
191 |
192 | if (Shopify.Context.IS_EMBEDDED_APP && req.query.embedded !== "1") {
193 | const embeddedUrl = Shopify.Utils.getEmbeddedAppUrl(req);
194 |
195 | return res.redirect(embeddedUrl + req.path);
196 | }
197 |
198 | const htmlFile = join(
199 | isProd ? PROD_INDEX_PATH : DEV_INDEX_PATH,
200 | "index.html",
201 | );
202 |
203 | return res
204 | .status(200)
205 | .set("Content-Type", "text/html")
206 | .send(readFileSync(htmlFile));
207 | });
208 |
209 | return { app };
210 | }
211 |
212 | createServer().then(({ app }) => app.listen(PORT));
213 |
--------------------------------------------------------------------------------
/web/helpers/ensure-billing.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BillingInterval,
3 | BillingSettings,
4 | SessionInterface,
5 | Shopify,
6 | } from "@shopify/shopify-api";
7 | import { GraphqlClient } from "@shopify/shopify-api/dist/clients/graphql";
8 |
9 | const RECURRING_INTERVALS = [
10 | BillingInterval.Every30Days,
11 | BillingInterval.Annual,
12 | ];
13 |
14 | let isProd: boolean;
15 |
16 | /**
17 | * You may want to charge merchants for using your app. This helper provides that function by checking if the current
18 | * merchant has an active one-time payment or subscription named `chargeName`. If no payment is found,
19 | * this helper requests it and returns a confirmation URL so that the merchant can approve the purchase.
20 | *
21 | * Learn more about billing in our documentation: https://shopify.dev/apps/billing
22 | */
23 | export default async function ensureBilling(
24 | session: SessionInterface,
25 | { chargeName, amount, currencyCode, interval }: BillingSettings,
26 | isProdOverride = process.env.NODE_ENV === "production",
27 | ) {
28 | if (!Object.values(BillingInterval).includes(interval)) {
29 | throw `Unrecognized billing interval '${interval}'`;
30 | }
31 |
32 | isProd = isProdOverride;
33 |
34 | let hasPayment;
35 | let confirmationUrl = null;
36 |
37 | if (await hasActivePayment(session, { chargeName, interval })) {
38 | hasPayment = true;
39 | } else {
40 | hasPayment = false;
41 | confirmationUrl = await requestPayment(session, {
42 | chargeName,
43 | amount,
44 | currencyCode,
45 | interval,
46 | });
47 | }
48 |
49 | return [hasPayment, confirmationUrl];
50 | }
51 |
52 | async function hasActivePayment(
53 | session: SessionInterface,
54 | { chargeName, interval }: Pick,
55 | ) {
56 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken);
57 |
58 | if (isRecurring(interval)) {
59 | const currentInstallations = await client.query<{
60 | data: {
61 | currentAppInstallation: {
62 | activeSubscriptions: { name: string; test: boolean }[];
63 | };
64 | };
65 | }>({
66 | data: RECURRING_PURCHASES_QUERY,
67 | });
68 | const subscriptions =
69 | currentInstallations.body.data.currentAppInstallation.activeSubscriptions;
70 |
71 | for (let i = 0, len = subscriptions.length; i < len; i++) {
72 | if (
73 | subscriptions[i].name === chargeName &&
74 | (!isProd || !subscriptions[i].test)
75 | ) {
76 | return true;
77 | }
78 | }
79 | } else {
80 | let purchases;
81 | let endCursor = null;
82 | do {
83 | // @ts-ignore
84 | const currentInstallations = await client.query({
85 | data: {
86 | query: ONE_TIME_PURCHASES_QUERY,
87 | variables: { endCursor },
88 | },
89 | });
90 | purchases =
91 | currentInstallations.body.data.currentAppInstallation.oneTimePurchases;
92 |
93 | for (let i = 0, len = purchases.edges.length; i < len; i++) {
94 | const node = purchases.edges[i].node;
95 | if (
96 | node.name === chargeName &&
97 | (!isProd || !node.test) &&
98 | node.status === "ACTIVE"
99 | ) {
100 | return true;
101 | }
102 | }
103 |
104 | endCursor = purchases.pageInfo.endCursor;
105 | } while (purchases.pageInfo.hasNextPage);
106 | }
107 |
108 | return false;
109 | }
110 |
111 | async function requestPayment(
112 | session: SessionInterface,
113 | { chargeName, amount, currencyCode, interval }: BillingSettings,
114 | ) {
115 | const client = new Shopify.Clients.Graphql(session.shop, session.accessToken);
116 | const returnUrl = `https://${Shopify.Context.HOST_NAME}?shop=${
117 | session.shop
118 | }&host=${Buffer.from(`${session.shop}/admin`).toString("base64")}`;
119 |
120 | let data;
121 | if (isRecurring(interval)) {
122 | const mutationResponse = await requestRecurringPayment(client, returnUrl, {
123 | chargeName,
124 | amount,
125 | currencyCode,
126 | interval,
127 | });
128 | data = mutationResponse.body.data.appSubscriptionCreate;
129 | } else {
130 | const mutationResponse = await requestSinglePayment(client, returnUrl, {
131 | chargeName,
132 | amount,
133 | currencyCode,
134 | });
135 | data = mutationResponse.body.data.appPurchaseOneTimeCreate;
136 | }
137 |
138 | if (data.userErrors.length) {
139 | throw new ShopifyBillingError(
140 | "Error while billing the store",
141 | data.userErrors,
142 | );
143 | }
144 |
145 | return data.confirmationUrl;
146 | }
147 |
148 | async function requestRecurringPayment(
149 | client: GraphqlClient,
150 | returnUrl: string,
151 | { chargeName, amount, currencyCode, interval }: BillingSettings,
152 | ) {
153 | const mutationResponse = await client.query<{
154 | data: any;
155 | errors?: any[];
156 | }>({
157 | data: {
158 | query: RECURRING_PURCHASE_MUTATION,
159 | variables: {
160 | name: chargeName,
161 | lineItems: [
162 | {
163 | plan: {
164 | appRecurringPricingDetails: {
165 | interval,
166 | price: { amount, currencyCode },
167 | },
168 | },
169 | },
170 | ],
171 | returnUrl,
172 | test: !isProd,
173 | },
174 | },
175 | });
176 |
177 | if (mutationResponse.body.errors && mutationResponse.body.errors.length) {
178 | throw new ShopifyBillingError(
179 | "Error while billing the store",
180 | mutationResponse.body.errors,
181 | );
182 | }
183 |
184 | return mutationResponse;
185 | }
186 |
187 | async function requestSinglePayment(
188 | client: GraphqlClient,
189 | returnUrl: string,
190 | { chargeName, amount, currencyCode }: Partial,
191 | ) {
192 | const mutationResponse = await client.query<{
193 | data: any;
194 | errors?: any[];
195 | }>({
196 | data: {
197 | query: ONE_TIME_PURCHASE_MUTATION,
198 | variables: {
199 | name: chargeName,
200 | price: { amount, currencyCode },
201 | returnUrl,
202 | test: process.env.NODE_ENV !== "production",
203 | },
204 | },
205 | });
206 |
207 | if (mutationResponse.body.errors && mutationResponse.body.errors.length) {
208 | throw new ShopifyBillingError(
209 | "Error while billing the store",
210 | mutationResponse.body.errors,
211 | );
212 | }
213 |
214 | return mutationResponse;
215 | }
216 |
217 | function isRecurring(interval: BillingInterval) {
218 | return RECURRING_INTERVALS.includes(interval);
219 | }
220 |
221 | export class ShopifyBillingError extends Error {
222 | constructor(message: string, public errorData: any[]) {
223 | super(message);
224 | this.name = "ShopifyBillingError";
225 | this.stack = new Error().stack;
226 | this.message = message;
227 | this.errorData = errorData;
228 | }
229 | }
230 |
231 | const RECURRING_PURCHASES_QUERY = `
232 | query appSubscription {
233 | currentAppInstallation {
234 | activeSubscriptions {
235 | name, test
236 | }
237 | }
238 | }
239 | `;
240 |
241 | const ONE_TIME_PURCHASES_QUERY = `
242 | query appPurchases($endCursor: String) {
243 | currentAppInstallation {
244 | oneTimePurchases(first: 250, sortKey: CREATED_AT, after: $endCursor) {
245 | edges {
246 | node {
247 | name, test, status
248 | }
249 | }
250 | pageInfo {
251 | hasNextPage, endCursor
252 | }
253 | }
254 | }
255 | }
256 | `;
257 |
258 | const RECURRING_PURCHASE_MUTATION = `
259 | mutation test(
260 | $name: String!
261 | $lineItems: [AppSubscriptionLineItemInput!]!
262 | $returnUrl: URL!
263 | $test: Boolean
264 | ) {
265 | appSubscriptionCreate(
266 | name: $name
267 | lineItems: $lineItems
268 | returnUrl: $returnUrl
269 | test: $test
270 | ) {
271 | confirmationUrl
272 | userErrors {
273 | field
274 | message
275 | }
276 | }
277 | }
278 | `;
279 |
280 | const ONE_TIME_PURCHASE_MUTATION = `
281 | mutation test(
282 | $name: String!
283 | $price: MoneyInput!
284 | $returnUrl: URL!
285 | $test: Boolean
286 | ) {
287 | appPurchaseOneTimeCreate(
288 | name: $name
289 | price: $price
290 | returnUrl: $returnUrl
291 | test: $test
292 | ) {
293 | confirmationUrl
294 | userErrors {
295 | field
296 | message
297 | }
298 | }
299 | }
300 | `;
301 |
--------------------------------------------------------------------------------
/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26 |
27 | /* Modules */
28 | "module": "ESNext", /* Specify what module code is generated. */
29 | // "rootDir": "./", /* Specify the root folder within your source files. */
30 | "moduleResolution": "Node", /* Specify how TypeScript looks up a file from a given module specifier. */
31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38 | // "resolveJsonModule": true, /* Enable importing .json files. */
39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
40 |
41 | /* JavaScript Support */
42 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
45 |
46 | /* Emit */
47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
52 | "outDir": "./dist", /* Specify an output folder for all emitted files. */
53 | // "removeComments": true, /* Disable emitting comments. */
54 | // "noEmit": true, /* Disable emitting files from a compilation. */
55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
63 | // "newLine": "crlf", /* Set the newline character for emitting files. */
64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
70 |
71 | /* Interop Constraints */
72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
77 |
78 | /* Type Checking */
79 | "strict": true, /* Enable all strict type-checking options. */
80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
98 |
99 | /* Completeness */
100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
102 | },
103 | "include": ["./**/*.ts"]
104 | }
105 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Shopify App Template - Node Typescript
2 |
3 |
4 | [](#contributors)
5 |
6 |
7 |
8 | This is a template for building a [Shopify app](https://shopify.dev/apps/getting-started) using Node and React using Typescript. It contains the basics for building a Shopify app.
9 |
10 | ## Contributors
11 |
12 |
13 |
14 |
15 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | # Original Repo Readme below
38 |
39 | ## Benefits
40 |
41 | Shopify apps are built on a variety of Shopify tools to create a great merchant experience. The [create an app](https://shopify.dev/apps/getting-started/create) tutorial in our developer documentation will guide you through creating a Shopify app using this template.
42 |
43 | The Node app template comes with the following out-of-the-box functionality:
44 |
45 | - OAuth: Installing the app and granting permissions
46 | - GraphQL Admin API: Querying or mutating Shopify admin data
47 | - REST Admin API: Resource classes to interact with the API
48 | - Shopify-specific tooling:
49 | - AppBridge
50 | - Polaris
51 | - Webhooks
52 |
53 | ## Tech Stack
54 |
55 | This template combines a number of third party open-source tools:
56 |
57 | - [Express](https://expressjs.com/) builds the backend.
58 | - [Vite](https://vitejs.dev/) builds the [React](https://reactjs.org/) frontend.
59 | - [React Router](https://reactrouter.com/) is used for routing. We wrap this with file-based routing.
60 | - [React Query](https://react-query.tanstack.com/) queries the Admin API.
61 |
62 | The following Shopify tools complement these third-party tools to ease app development:
63 |
64 | - [Shopify API library](https://github.com/Shopify/shopify-node-api) adds OAuth to the Express backend. This lets users install the app and grant scope permissions.
65 | - [App Bridge React](https://shopify.dev/apps/tools/app-bridge/getting-started/using-react) adds authentication to API requests in the frontend and renders components outside of the App’s iFrame.
66 | - [Polaris React](https://polaris.shopify.com/) is a powerful design system and component library that helps developers build high quality, consistent experiences for Shopify merchants.
67 | - [Custom hooks](https://github.com/Shopify/shopify-frontend-template-react/tree/main/hooks) make authenticated requests to the Admin API.
68 | - [File-based routing](https://github.com/Shopify/shopify-frontend-template-react/blob/main/Routes.jsx) makes creating new pages easier.
69 |
70 | ## Getting started
71 |
72 | ### Requirements
73 |
74 | 1. You must [download and install Node.js](https://nodejs.org/en/download/) if you don't already have it.
75 | 1. You must [create a Shopify partner account](https://partners.shopify.com/signup) if you don’t have one.
76 | 1. You must [create a development store](https://help.shopify.com/en/partners/dashboard/development-stores#create-a-development-store) if you don’t have one.
77 |
78 | ### Installing the template
79 |
80 | Clone this repository :D
81 |
82 | #### Local Development
83 |
84 | [The Shopify CLI](https://shopify.dev/apps/tools/cli) connects to an app in your Partners dashboard. It provides environment variables, runs commands in parallel, and updates application URLs for easier development.
85 |
86 | You can develop locally using your preferred package manager. Run one of the following commands from the root of your app.
87 |
88 | Using yarn:
89 |
90 | ```shell
91 | yarn dev
92 | ```
93 |
94 | Using npm:
95 |
96 | ```shell
97 | npm run dev
98 | ```
99 |
100 | Using pnpm:
101 |
102 | ```shell
103 | pnpm run dev
104 | ```
105 |
106 | Open the URL generated in your console. Once you grant permission to the app, you can start development.
107 |
108 | ## Deployment
109 |
110 | ### Application Storage
111 |
112 | This template uses [SQLite](https://www.sqlite.org/index.html) to store session data. The database is a file called `database.sqlite` which is automatically created in the root. This use of SQLite works in production if your app runs as a single instance.
113 |
114 | The database that works best for you depends on the data your app needs and how it is queried. You can run your database of choice on a server yourself or host it with a SaaS company. Here’s a short list of databases providers that provide a free tier to get started:
115 |
116 | | Database | Type | Hosters |
117 | | ---------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
118 | | MySQL | SQL | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-mysql), [Planet Scale](https://planetscale.com/), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/mysql) |
119 | | PostgreSQL | SQL | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-postgresql), [Amazon Aurora](https://aws.amazon.com/rds/aurora/), [Google Cloud SQL](https://cloud.google.com/sql/docs/postgres) |
120 | | Redis | Key-value | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-redis), [Amazon MemoryDB](https://aws.amazon.com/memorydb/) |
121 | | MongoDB | NoSQL / Document | [Digital Ocean](https://www.digitalocean.com/try/managed-databases-mongodb), [MongoDB Atlas](https://www.mongodb.com/atlas/database) |
122 |
123 | To use one of these, you need to change your session storage configuration. To help, here’s a list of [SessionStorage adapters](https://github.com/Shopify/shopify-api-node/tree/main/src/auth/session/storage).
124 |
125 | ### Build
126 |
127 | The frontend is a single page app. It requires the `SHOPIFY_API_KEY`, which you can find on the page for your app in your partners dashboard. Paste your app’s key in the command for the package manager of your choice:
128 |
129 | Using yarn:
130 |
131 | ```shell
132 | cd web/frontend/ && SHOPIFY_API_KEY=REPLACE_ME yarn build
133 | ```
134 |
135 | Using npm:
136 |
137 | ```shell
138 | cd web/frontend/ && SHOPIFY_API_KEY=REPLACE_ME npm run build
139 | ```
140 |
141 | Using pnpm:
142 |
143 | ```shell
144 | cd web/frontend/ && SHOPIFY_API_KEY=REPLACE_ME pnpm run build
145 | ```
146 |
147 | You do not need to build the backend.
148 |
149 | ## Hosting
150 |
151 | When you're ready to set up your app in production, you can follow [our deployment documentation](https://shopify.dev/apps/deployment/web) to host your app on a cloud provider like [Heroku](https://www.heroku.com/) or [Fly.io](https://fly.io/).
152 |
153 | When you reach the step for [setting up environment variables](https://shopify.dev/apps/deployment/web#set-env-vars), you also need to set the variable `NODE_ENV=production`.
154 |
155 | ## Some things to watch out for
156 |
157 | ### Using `express.json` middleware
158 |
159 | If you use the `express.json()` middleware in your app **and** if you use `Shopify.Webhooks.Registry.process()` to process webhooks API calls from Shopify (which we recommend), the webhook processing must occur **_before_** calling `app.use(express.json())`. See the [API documentation](https://github.com/Shopify/shopify-api-node/blob/main/docs/usage/webhooks.md#note-regarding-use-of-body-parsers) for more details.
160 |
161 | ## Known issues
162 |
163 | ### Hot module replacement and Firefox
164 |
165 | When running the app with the CLI in development mode on Firefox, you might see your app constantly reloading when you access it.
166 | That happened in previous versions of the CLI, because of the way HMR websocket requests work.
167 |
168 | We fixed this issue with v3.4.0 of the CLI, so after updating it, you can make the following changes to your app's `web/frontend/vite.config.js` file:
169 |
170 | 1. Change the definition `hmrConfig` object to be:
171 |
172 | ```js
173 | const host = process.env.HOST
174 | ? process.env.HOST.replace(/https?:\/\//, "")
175 | : "localhost";
176 |
177 | let hmrConfig;
178 | if (host === "localhost") {
179 | hmrConfig = {
180 | protocol: "ws",
181 | host: "localhost",
182 | port: 64999,
183 | clientPort: 64999,
184 | };
185 | } else {
186 | hmrConfig = {
187 | protocol: "wss",
188 | host: host,
189 | port: process.env.FRONTEND_PORT,
190 | clientPort: 443,
191 | };
192 | }
193 | ```
194 |
195 | 1. Change the `server.host` setting in the configs to `"localhost"`:
196 |
197 | ```js
198 | server: {
199 | host: "localhost",
200 | ...
201 | ```
202 |
203 | ### I can't get past the ngrok "Visit site" page
204 |
205 | When you’re previewing your app or extension, you might see an ngrok interstitial page with a warning:
206 |
207 | ```text
208 | You are about to visit .ngrok.io: Visit Site
209 | ```
210 |
211 | If you click the `Visit Site` button, but continue to see this page, then you should run dev using an alternate tunnel URL that you run using tunneling software.
212 | We've validated that [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/run-tunnel/trycloudflare/) works with this template.
213 |
214 | To do that, you can [install the `cloudflared` CLI tool](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/), and run:
215 |
216 | ```shell
217 | # Note that you can also use a different port
218 | cloudflared tunnel --url http://localhost:3000
219 | ```
220 |
221 | In a different terminal window, navigate to your app's root and call:
222 |
223 | ```shell
224 | # Using yarn
225 | yarn dev --tunnel-url https://tunnel-url:3000
226 | # or using npm
227 | npm run dev --tunnel-url https://tunnel-url:3000
228 | # or using pnpm
229 | pnpm dev --tunnel-url https://tunnel-url:3000
230 | ```
231 |
232 | ## Developer resources
233 |
234 | - [Introduction to Shopify apps](https://shopify.dev/apps/getting-started)
235 | - [App authentication](https://shopify.dev/apps/auth)
236 | - [Shopify CLI](https://shopify.dev/apps/tools/cli)
237 | - [Shopify API Library documentation](https://github.com/Shopify/shopify-api-node/tree/main/docs)
238 |
--------------------------------------------------------------------------------