Skip to content

Commit c730687

Browse files
Merge pull request #358 from Shopify/add-gotcha
Add a gotcha for streaming responses
1 parent 0d03107 commit c730687

16 files changed

+901
-2
lines changed

.graphqlrc.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import fs from "fs";
2+
import { LATEST_API_VERSION } from "@shopify/shopify-api";
3+
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
4+
import type { IGraphQLConfig } from "graphql-config";
5+
6+
function getConfig() {
7+
const config: IGraphQLConfig = {
8+
projects: {
9+
default: shopifyApiProject({
10+
apiType: ApiType.Admin,
11+
apiVersion: LATEST_API_VERSION,
12+
documents: ["./app/**/*.{js,ts,jsx,tsx}"],
13+
outputDir: "./app/types",
14+
}),
15+
},
16+
};
17+
18+
let extensions: string[] = [];
19+
try {
20+
extensions = fs.readdirSync("./extensions");
21+
} catch {
22+
// ignore if no extensions
23+
}
24+
25+
for (const entry of extensions) {
26+
const extensionPath = `./extensions/${entry}`;
27+
const schema = `${extensionPath}/schema.graphql`;
28+
if (!fs.existsSync(schema)) {
29+
continue;
30+
}
31+
config.projects[entry] = {
32+
schema,
33+
documents: [`${extensionPath}/**/*.graphql`],
34+
};
35+
}
36+
37+
return config;
38+
}
39+
40+
module.exports = getConfig();

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ When you reach the step for [setting up environment variables](https://shopify.d
151151

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

154-
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.
154+
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.
155155

156156
```diff
157157
// shopify.server.ts
@@ -216,6 +216,7 @@ pnpm run deploy
216216

217217
This template registers webhooks after OAuth completes, using the `afterAuth` hook when calling `shopifyApp`.
218218
The package calls that hook in 2 scenarios:
219+
219220
- After installing the app
220221
- When an access token expires
221222

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

229230
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.
230231

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

233234
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`).
234235

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

252253
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).
253254

255+
### Using Defer & await for streaming responses
256+
257+
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:
258+
259+
1. First setup ngrok: https://ngrok.com/product/secure-tunnels
260+
2. Create an ngrok tunnel on port 8080: `ngrok http 8080`.
261+
3. Copy the forwarding address. This should be something like: `https://f355-2607-fea8-bb5c-8700-7972-d2b5-3f2b-94ab.ngrok-free.app`
262+
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.
263+
264+
By default the CLI uses a cloudflare tunnel. Unfortunately it cloudflare tunnels wait for the Response stream to finish, then sends one chunk.
265+
266+
This will not affect production, since tunnels are only for local development.
267+
254268
## Benefits
255269

256270
Shopify apps are built on a variety of Shopify tools to create a great merchant experience.

app/db.server.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { PrismaClient } from "@prisma/client";
2+
3+
declare global {
4+
var prisma: PrismaClient;
5+
}
6+
7+
const prisma: PrismaClient = global.prisma || new PrismaClient();
8+
9+
if (process.env.NODE_ENV !== "production") {
10+
if (!global.prisma) {
11+
global.prisma = new PrismaClient();
12+
}
13+
}
14+
15+
export default prisma;

app/entry.server.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { PassThrough } from "stream";
2+
import { renderToPipeableStream } from "react-dom/server";
3+
import { RemixServer } from "@remix-run/react";
4+
import {
5+
createReadableStreamFromReadable,
6+
type EntryContext,
7+
} from "@remix-run/node";
8+
import { isbot } from "isbot";
9+
import { addDocumentResponseHeaders } from "./shopify.server";
10+
11+
const ABORT_DELAY = 5000;
12+
13+
export default async function handleRequest(
14+
request: Request,
15+
responseStatusCode: number,
16+
responseHeaders: Headers,
17+
remixContext: EntryContext
18+
) {
19+
addDocumentResponseHeaders(request, responseHeaders);
20+
const userAgent = request.headers.get("user-agent");
21+
const callbackName = isbot(userAgent ?? '')
22+
? "onAllReady"
23+
: "onShellReady";
24+
25+
return new Promise((resolve, reject) => {
26+
const { pipe, abort } = renderToPipeableStream(
27+
<RemixServer
28+
context={remixContext}
29+
url={request.url}
30+
abortDelay={ABORT_DELAY}
31+
/>,
32+
{
33+
[callbackName]: () => {
34+
const body = new PassThrough();
35+
const stream = createReadableStreamFromReadable(body);
36+
37+
responseHeaders.set("Content-Type", "text/html");
38+
resolve(
39+
new Response(stream, {
40+
headers: responseHeaders,
41+
status: responseStatusCode,
42+
})
43+
);
44+
pipe(body);
45+
},
46+
onShellError(error) {
47+
reject(error);
48+
},
49+
onError(error) {
50+
responseStatusCode = 500;
51+
console.error(error);
52+
},
53+
}
54+
);
55+
56+
setTimeout(abort, ABORT_DELAY);
57+
});
58+
}

app/globals.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare module "*.css";

app/root.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
Links,
3+
Meta,
4+
Outlet,
5+
Scripts,
6+
ScrollRestoration,
7+
} from "@remix-run/react";
8+
9+
export default function App() {
10+
return (
11+
<html>
12+
<head>
13+
<meta charSet="utf-8" />
14+
<meta name="viewport" content="width=device-width,initial-scale=1" />
15+
<link rel="preconnect" href="https://cdn.shopify.com/" />
16+
<link
17+
rel="stylesheet"
18+
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
19+
/>
20+
<Meta />
21+
<Links />
22+
</head>
23+
<body>
24+
<Outlet />
25+
<ScrollRestoration />
26+
<Scripts />
27+
</body>
28+
</html>
29+
);
30+
}

app/routes/_index/route.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { LoaderFunctionArgs } from "@remix-run/node";
2+
import { json, redirect } from "@remix-run/node";
3+
import { Form, useLoaderData } from "@remix-run/react";
4+
5+
import { login } from "../../shopify.server";
6+
7+
import styles from "./styles.module.css";
8+
9+
export const loader = async ({ request }: LoaderFunctionArgs) => {
10+
const url = new URL(request.url);
11+
12+
if (url.searchParams.get("shop")) {
13+
throw redirect(`/app?${url.searchParams.toString()}`);
14+
}
15+
16+
return json({ showForm: Boolean(login) });
17+
};
18+
19+
export default function App() {
20+
const { showForm } = useLoaderData<typeof loader>();
21+
22+
return (
23+
<div className={styles.index}>
24+
<div className={styles.content}>
25+
<h1 className={styles.heading}>A short heading about [your app]</h1>
26+
<p className={styles.text}>
27+
A tagline about [your app] that describes your value proposition.
28+
</p>
29+
{showForm && (
30+
<Form className={styles.form} method="post" action="/auth/login">
31+
<label className={styles.label}>
32+
<span>Shop domain</span>
33+
<input className={styles.input} type="text" name="shop" />
34+
<span>e.g: my-shop-domain.myshopify.com</span>
35+
</label>
36+
<button className={styles.button} type="submit">
37+
Log in
38+
</button>
39+
</Form>
40+
)}
41+
<ul className={styles.list}>
42+
<li>
43+
<strong>Product feature</strong>. Some detail about your feature and
44+
its benefit to your customer.
45+
</li>
46+
<li>
47+
<strong>Product feature</strong>. Some detail about your feature and
48+
its benefit to your customer.
49+
</li>
50+
<li>
51+
<strong>Product feature</strong>. Some detail about your feature and
52+
its benefit to your customer.
53+
</li>
54+
</ul>
55+
</div>
56+
</div>
57+
);
58+
}

0 commit comments

Comments
 (0)