Skip to content

Commit

Permalink
Merge pull request #358 from Shopify/add-gotcha
Browse files Browse the repository at this point in the history
Add a gotcha for streaming responses
  • Loading branch information
byrichardpowell authored May 1, 2024
1 parent 0d03107 commit c730687
Show file tree
Hide file tree
Showing 16 changed files with 901 additions and 2 deletions.
40 changes: 40 additions & 0 deletions .graphqlrc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import fs from "fs";
import { LATEST_API_VERSION } from "@shopify/shopify-api";
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
import type { IGraphQLConfig } from "graphql-config";

function getConfig() {
const config: IGraphQLConfig = {
projects: {
default: shopifyApiProject({
apiType: ApiType.Admin,
apiVersion: LATEST_API_VERSION,
documents: ["./app/**/*.{js,ts,jsx,tsx}"],
outputDir: "./app/types",
}),
},
};

let extensions: string[] = [];
try {
extensions = fs.readdirSync("./extensions");
} catch {
// ignore if no extensions
}

for (const entry of extensions) {
const extensionPath = `./extensions/${entry}`;
const schema = `${extensionPath}/schema.graphql`;
if (!fs.existsSync(schema)) {
continue;
}
config.projects[entry] = {
schema,
documents: [`${extensionPath}/**/*.graphql`],
};
}

return config;
}

module.exports = getConfig();
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ When you reach the step for [setting up environment variables](https://shopify.d

When hosting your Shopify Remix app on Vercel, Vercel uses a fork of the [Remix library](https://github.com/vercel/remix).

To ensure all global variables are set correctly when you deploy your app to Vercel update your app to use the Vercel adapter instead of the node adapter.
To ensure all global variables are set correctly when you deploy your app to Vercel update your app to use the Vercel adapter instead of the node adapter.

```diff
// shopify.server.ts
Expand Down Expand Up @@ -216,6 +216,7 @@ pnpm run deploy

This template registers webhooks after OAuth completes, using the `afterAuth` hook when calling `shopifyApp`.
The package calls that hook in 2 scenarios:

- After installing the app
- When an access token expires

Expand All @@ -228,7 +229,7 @@ That will force the OAuth process and call the `afterAuth` hook.

Webhooks subscriptions created in the [Shopify admin](https://help.shopify.com/en/manual/orders/notifications/webhooks) will fail HMAC validation. This is because the webhook payload is not signed with your app's secret key.

Create [webhook subscriptions]((https://shopify.dev/docs/api/shopify-app-remix/v1/guide-webhooks)) using the `shopifyApp` object instead.
Create [webhook subscriptions](https://shopify.dev/docs/api/shopify-app-remix/v1/guide-webhooks) using the `shopifyApp` object instead.

Test your webhooks with the [Shopify CLI](https://shopify.dev/docs/apps/tools/cli/commands#webhook-trigger) or by triggering events manually in the Shopify admin(e.g. Updating the product title to trigger a `PRODUCTS_UPDATE`).

Expand All @@ -251,6 +252,19 @@ When you trigger a webhook event using the Shopify CLI, the `admin` object will

Webhooks triggered by the CLI are intended for initial experimentation testing of your webhook configuration. For more information on how to test your webhooks, see the [Shopify CLI documentation](https://shopify.dev/docs/apps/tools/cli/commands#webhook-trigger).

### Using Defer & await for streaming responses

To test [streaming using defer/await](https://remix.run/docs/en/main/guides/streaming) during local development you'll need to use the Shopify CLI slightly differently:

1. First setup ngrok: https://ngrok.com/product/secure-tunnels
2. Create an ngrok tunnel on port 8080: `ngrok http 8080`.
3. Copy the forwarding address. This should be something like: `https://f355-2607-fea8-bb5c-8700-7972-d2b5-3f2b-94ab.ngrok-free.app`
4. In a separate terminal run `yarn shopify app dev --tunnel-url=TUNNEL_URL:8080` replacing `TUNNEL_URL` for the address you copied in step 3.

By default the CLI uses a cloudflare tunnel. Unfortunately it cloudflare tunnels wait for the Response stream to finish, then sends one chunk.

This will not affect production, since tunnels are only for local development.

## Benefits

Shopify apps are built on a variety of Shopify tools to create a great merchant experience.
Expand Down
15 changes: 15 additions & 0 deletions app/db.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";

declare global {
var prisma: PrismaClient;
}

const prisma: PrismaClient = global.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
}

export default prisma;
58 changes: 58 additions & 0 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { PassThrough } from "stream";
import { renderToPipeableStream } from "react-dom/server";
import { RemixServer } from "@remix-run/react";
import {
createReadableStreamFromReadable,
type EntryContext,
} from "@remix-run/node";
import { isbot } from "isbot";
import { addDocumentResponseHeaders } from "./shopify.server";

const ABORT_DELAY = 5000;

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
addDocumentResponseHeaders(request, responseHeaders);
const userAgent = request.headers.get("user-agent");
const callbackName = isbot(userAgent ?? '')
? "onAllReady"
: "onShellReady";

return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
[callbackName]: () => {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error) {
reject(error);
},
onError(error) {
responseStatusCode = 500;
console.error(error);
},
}
);

setTimeout(abort, ABORT_DELAY);
});
}
1 change: 1 addition & 0 deletions app/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*.css";
30 changes: 30 additions & 0 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";

export default function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://cdn.shopify.com/" />
<link
rel="stylesheet"
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
/>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
58 changes: 58 additions & 0 deletions app/routes/_index/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";

import { login } from "../../shopify.server";

import styles from "./styles.module.css";

export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);

if (url.searchParams.get("shop")) {
throw redirect(`/app?${url.searchParams.toString()}`);
}

return json({ showForm: Boolean(login) });
};

export default function App() {
const { showForm } = useLoaderData<typeof loader>();

return (
<div className={styles.index}>
<div className={styles.content}>
<h1 className={styles.heading}>A short heading about [your app]</h1>
<p className={styles.text}>
A tagline about [your app] that describes your value proposition.
</p>
{showForm && (
<Form className={styles.form} method="post" action="/auth/login">
<label className={styles.label}>
<span>Shop domain</span>
<input className={styles.input} type="text" name="shop" />
<span>e.g: my-shop-domain.myshopify.com</span>
</label>
<button className={styles.button} type="submit">
Log in
</button>
</Form>
)}
<ul className={styles.list}>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
</ul>
</div>
</div>
);
}
Loading

0 comments on commit c730687

Please sign in to comment.