@shopify/shopify-app-remix
Version:
Shopify Remix - to simplify the building of Shopify Apps with Remix
508 lines (483 loc) • 17.1 kB
text/typescript
import {RegisterReturn, Shopify} from '@shopify/shopify-api';
import {SessionStorage} from '@shopify/shopify-app-session-storage';
import type {AuthenticateAdmin} from './authenticate/admin/types';
import type {AuthenticateFlow} from './authenticate/flow/types';
import {AuthenticateFulfillmentService} from './authenticate/fulfillment-service/types';
import type {AuthenticatePublic} from './authenticate/public/types';
import type {
AuthenticateWebhook,
RegisterWebhooksOptions,
} from './authenticate/webhooks/types';
import type {AppConfig, AppConfigArg} from './config-types';
import type {
ApiConfigWithFutureFlags,
ApiFutureFlags,
FutureFlagOptions,
} from './future/flags';
import type {Unauthenticated} from './unauthenticated/types';
export interface BasicParams<
Future extends FutureFlagOptions = FutureFlagOptions,
> {
api: Shopify<ApiConfigWithFutureFlags<Future>, ApiFutureFlags<Future>>;
config: AppConfig;
logger: Shopify['logger'];
}
// eslint-disable-next-line no-warning-comments
// TODO: Use this enum to replace the isCustomStoreApp config option in shopify-api-js
export enum AppDistribution {
AppStore = 'app_store',
SingleMerchant = 'single_merchant',
ShopifyAdmin = 'shopify_admin',
}
type RegisterWebhooks = (
options: RegisterWebhooksOptions,
) => Promise<RegisterReturn | void>;
export enum LoginErrorType {
MissingShop = 'MISSING_SHOP',
InvalidShop = 'INVALID_SHOP',
}
export interface LoginError {
shop?: LoginErrorType;
}
type Login = (request: Request) => Promise<LoginError | never>;
type AddDocumentResponseHeaders = (request: Request, headers: Headers) => void;
type SessionStorageType<Config extends AppConfigArg> =
Config['sessionStorage'] extends SessionStorage
? Config['sessionStorage']
: SessionStorage;
interface Authenticate<Config extends AppConfigArg> {
/**
* Authenticate an admin Request and get back an authenticated admin context. Use the authenticated admin context to interact with Shopify.
*
* Examples of when to use this are requests from your app's UI, or requests from admin extensions.
*
* If there is no session for the Request, this will redirect the merchant to correct auth flows.
*
* @example
* <caption>Authenticating a request for an embedded app.</caption>
* ```ts
* // /app/routes/**\/*.jsx
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../../shopify.server";
*
* export async function loader({ request }: LoaderFunctionArgs) {
* const {admin, session, sessionToken, billing} = authenticate.admin(request);
* const response = await admin.graphql(`{ shop { name } }`)
*
* return json(await response.json());
* }
* ```
* ```ts
* // /app/shopify.server.ts
* import { ApiVersion, shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...etc
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
admin: AuthenticateAdmin<Config>;
/**
* Authenticate a Flow extension Request and get back an authenticated context, containing an admin context to access
* the API, and the payload of the request.
*
* If there is no session for the Request, this will return an HTTP 400 error.
*
* Note that this will always be a POST request.
*
* @example
* <caption>Authenticating a Flow extension request.</caption>
* ```ts
* // /app/routes/**\/*.jsx
* import { ActionFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../../shopify.server";
*
* export async function action({ request }: ActionFunctionArgs) {
* const {admin, session, payload} = authenticate.flow(request);
*
* // Perform flow extension logic
*
* // Return a 200 response
* return null;
* }
* ```
* ```ts
* // /app/shopify.server.ts
* import { ApiVersion, shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...etc
* });
* export default shopify;
* export const authenticate = shopify.authenticate;
* ```
*/
flow: AuthenticateFlow;
/**
* Authenticate a request from a fulfillment service and get back an authenticated context.
*
* @example
* <caption>Shopify session for the fulfillment service request.</caption>
* <description>Use the session associated with this request to use the Admin GraphQL API </description>
* ```ts
* // /app/routes/fulfillment_order_notification.ts
* import { ActionFunctionArgs } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
*
* export async function action({ request }: ActionFunctionArgs) {
* const { admin, session } = await authenticate.fulfillmentService(request);
*
* console.log(session.id)
*
* return new Response();
* }
* ```
* */
fulfillmentService: AuthenticateFulfillmentService;
/**
* Authenticate a public request and get back a session token.
*
* @example
* <caption>Authenticating a request from a checkout extension</caption>
*
* ```ts
* // /app/routes/api/checkout.jsx
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticate } from "../../shopify.server";
* import { getWidgets } from "~/db/widgets";
*
* export async function loader({ request }: LoaderFunctionArgs) {
* const {sessionToken} = authenticate.public.checkout(request);
*
* return json(await getWidgets(sessionToken));
* }
* ```
*/
public: AuthenticatePublic;
/**
* Authenticate a Shopify webhook request, get back an authenticated admin context and details on the webhook request
*
* @example
* <caption>Authenticating a webhook request</caption>
*
* ```ts
* // app/routes/webhooks.ts
* import { ActionFunctionArgs } from "@remix-run/node";
* import { authenticate } from "../shopify.server";
* import db from "../db.server";
*
* export const action = async ({ request }: ActionFunctionArgs) => {
* const { topic, shop, session, payload } = await authenticate.webhook(request);
*
* // Webhook requests can trigger after an app is uninstalled
* // If the app is already uninstalled, the session may be undefined.
* if (!session) {
* throw new Response();
* }
*
* // Handle the webhook
* console.log(`${TOPIC} webhook received with payload:`, JSON.stringify(payload))
*
* throw new Response();
* };
* ```
*
* @example
* <caption>Registering app-specific webhooks (Recommended)</caption>
* ```toml
* # shopify.app.toml
* [webhooks]
* api_version = "2024-07"
* [[webhooks.subscriptions]]
* topics = ["products/create"]
* uri = "/webhooks/products/create"
*
* ```
*
* @example
* <caption>Registering shop-specific webhooks.</caption>
* <description>In many cases you won't need this. Please see: [https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-vs-shop-specific-subscriptions](https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-vs-shop-specific-subscriptions)
* </description>
* ```ts
* // app/shopify.server.ts
* import { DeliveryMethod, shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* webhooks: {
* PRODUCTS_CREATE: {
* deliveryMethod: DeliveryMethod.Http,
* callbackUrl: "/webhooks/products/create",
* },
* },
* hooks: {
* afterAuth: async ({ session }) => {
* // Register webhooks for the shop
* // In this example, every shop will have these webhooks
* // You could wrap this in some custom shop specific conditional logic if needed
* shopify.registerWebhooks({ session });
* },
* },
* // ...etc
* });
* ```
*/
webhook: AuthenticateWebhook<string>;
}
export interface ShopifyAppBase<Config extends AppConfigArg> {
/**
* The `SessionStorage` instance you passed in as a config option.
*
* @example
* <caption>Storing sessions with Prisma.</caption>
* <description>Import the `@shopify/shopify-app-session-storage-prisma` package to store sessions in your Prisma database.</description>
* ```ts
* // /app/shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
* import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
* import prisma from "~/db.server";
*
* const shopify = shopifyApp({
* sessionStorage: new PrismaSessionStorage(prisma),
* // ...etc
* })
*
* // shopify.sessionStorage is an instance of PrismaSessionStorage
* ```
*/
sessionStorage?: SessionStorageType<Config>;
/**
* Adds the required Content Security Policy headers for Shopify apps to the given Headers object.
*
* {@link https://shopify.dev/docs/apps/store/security/iframe-protection}
*
* @example
* <caption>Return headers on all requests.</caption>
* <description>Add headers to all HTML requests by calling `shopify.addDocumentResponseHeaders` in `entry.server.tsx`.</description>
*
* ```
* // ~/shopify.server.ts
* import { shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...etc
* });
* export default shopify;
* export const addDocumentResponseheaders = shopify.addDocumentResponseheaders;
* ```
*
* ```ts
* // entry.server.tsx
* import { addDocumentResponseHeaders } from "~/shopify.server";
*
* export default function handleRequest(
* request: Request,
* responseStatusCode: number,
* responseHeaders: Headers,
* remixContext: EntryContext
* ) {
* const markup = renderToString(
* <RemixServer context={remixContext} url={request.url} />
* );
*
* responseHeaders.set("Content-Type", "text/html");
* addDocumentResponseHeaders(request, responseHeaders);
*
* return new Response("<!DOCTYPE html>" + markup, {
* status: responseStatusCode,
* headers: responseHeaders,
* });
* }
* ```
*/
addDocumentResponseHeaders: AddDocumentResponseHeaders;
/**
* Register shop-specific webhook subscriptions using the Admin GraphQL API.
*
* In many cases defining app-specific webhooks in the `shopify.app.toml` will be sufficient and easier to manage. Please see:
*
* {@link https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-vs-shop-specific-subscriptions}
*
* You should only use this if you need shop-specific webhooks.
*
* @example
* <caption>Registering shop-specific webhooks after install</caption>
* <description>Trigger the registration to create the shop-specific webhook subscriptions after a merchant installs your app using the `afterAuth` hook. Learn more about [subscribing to webhooks.](https://shopify.dev/docs/api/shopify-app-remix/v3/guide-webhooks)</description>
* ```ts
* // app/shopify.server.ts
* import { DeliveryMethod, shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* webhooks: {
* PRODUCTS_CREATE: {
* deliveryMethod: DeliveryMethod.Http,
* callbackUrl: "/webhooks/products/create",
* },
* },
* hooks: {
* afterAuth: async ({ session }) => {
* // Register webhooks for the shop
* // In this example, every shop will have these webhooks
* // You could wrap this in some custom shop specific conditional logic if needed
* shopify.registerWebhooks({ session });
* },
* },
* // ...etc
* });
* ```
*/
registerWebhooks: RegisterWebhooks;
/**
* Ways to authenticate requests from different surfaces across Shopify.
*
* @example
* <caption>Authenticate Shopify requests.</caption>
* <description>Use the functions in `authenticate` to validate requests coming from Shopify.</description>
* ```ts
* // /app/shopify.server.ts
* import { ApiVersion, shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...etc
* });
* export default shopify;
* ```
* ```ts
* // /app/routes/**\/*.jsx
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import shopify from "../../shopify.server";
*
* export async function loader({ request }: LoaderFunctionArgs) {
* const {admin, session, sessionToken, billing} = shopify.authenticate.admin(request);
* const response = admin.graphql(`{ shop { name } }`)
*
* return json(await response.json());
* }
* ```
*/
authenticate: Authenticate<Config>;
/**
* Ways to get Contexts from requests that do not originate from Shopify.
*
* @example
* <caption>Using unauthenticated contexts.</caption>
* <description>Create contexts for requests that don't come from Shopify.</description>
* ```ts
* // /app/shopify.server.ts
* import { ApiVersion, shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...etc
* });
* export default shopify;
* ```
* ```ts
* // /app/routes/**\/*.jsx
* import { LoaderFunctionArgs, json } from "@remix-run/node";
* import { authenticateExternal } from "~/helpers/authenticate"
* import shopify from "../../shopify.server";
*
* export async function loader({ request }: LoaderFunctionArgs) {
* const shop = await authenticateExternal(request)
* const {admin} = await shopify.unauthenticated.admin(shop);
* const response = admin.graphql(`{ shop { currencyCode } }`)
*
* return json(await response.json());
* }
* ```
*/
unauthenticated: Unauthenticated;
}
export interface ShopifyAppLogin {
/**
* Log a merchant in, and redirect them to the app root. Will redirect the merchant to authentication if a shop is
* present in the URL search parameters or form data.
*
* This function won't be present when the `distribution` config option is set to `AppDistribution.ShopifyAdmin`,
* because Admin apps aren't allowed to show a login page.
*
* @example
* <caption>Creating a login page.</caption>
* <description>Use `shopify.login` to create a login form, in a route that can handle GET and POST requests.</description>
* ```ts
* // /app/shopify.server.ts
* import { ApiVersion, shopifyApp } from "@shopify/shopify-app-remix/server";
*
* const shopify = shopifyApp({
* // ...etc
* });
* export default shopify;
* ```
* ```ts
* // /app/routes/auth/login.tsx
* import shopify from "../../shopify.server";
*
* export async function loader({ request }: LoaderFunctionArgs) {
* const errors = shopify.login(request);
*
* return json(errors);
* }
*
* export async function action({ request }: ActionFunctionArgs) {
* const errors = shopify.login(request);
*
* return json(errors);
* }
*
* export default function Auth() {
* const actionData = useActionData<typeof action>();
* const [shop, setShop] = useState("");
*
* return (
* <Page>
* <Card>
* <Form method="post">
* <FormLayout>
* <Text variant="headingMd" as="h2">
* Login
* </Text>
* <TextField
* type="text"
* name="shop"
* label="Shop domain"
* helpText="e.g: my-shop-domain.myshopify.com"
* value={shop}
* onChange={setShop}
* autoComplete="on"
* error={actionData?.errors.shop}
* />
* <Button submit primary>
* Submit
* </Button>
* </FormLayout>
* </Form>
* </Card>
* </Page>
* );
* }
* ```
*/
login: Login;
}
export type AdminApp<Config extends AppConfigArg> = ShopifyAppBase<Config>;
export type SingleMerchantApp<Config extends AppConfigArg> =
ShopifyAppBase<Config> & ShopifyAppLogin;
export type AppStoreApp<Config extends AppConfigArg> = ShopifyAppBase<Config> &
ShopifyAppLogin;
type EnforceSessionStorage<Config extends AppConfigArg, Base> = Base & {
sessionStorage: SessionStorageType<Config>;
};
/**
* An object your app can use to interact with Shopify.
*
* By default, the app's distribution is `AppStore`.
*/
export type ShopifyApp<Config extends AppConfigArg> =
Config['distribution'] extends AppDistribution.ShopifyAdmin
? AdminApp<Config>
: Config['distribution'] extends AppDistribution.SingleMerchant
? EnforceSessionStorage<Config, SingleMerchantApp<Config>>
: Config['distribution'] extends AppDistribution.AppStore
? EnforceSessionStorage<Config, AppStoreApp<Config>>
: EnforceSessionStorage<Config, AppStoreApp<Config>>;