Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 94 additions & 17 deletions cookbook/llms/partytown.prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ Document Partytown setup and configuration instructions.
+
+1. **Partytown** runs third-party scripts in a web worker, keeping the main thread free
+2. **GTM scripts** are loaded with `type="text/partytown"` to run in the worker
+3. **Reverse proxy** handles scripts that need CORS headers
+3. **Reverse proxy** handles scripts that need CORS headers, rejects upstream responses without a JavaScript content type, and blocks redirects outside the allowlist
+4. **CSP headers** are configured to allow GTM and Google Analytics domains
+
+### Performance benefits
Expand Down Expand Up @@ -270,11 +270,13 @@ Reverse the proxy route for third-party scripts requiring CORS headers.

import type {Route} from './+types/reverse-proxy';

type HandleRequestResponHeaders = {
type ProxyResponseHeaders = {
'Access-Control-Allow-Origin': string;
'Content-Security-Policy': string;
Vary: string;
'X-Content-Type-Options': string;
'cache-control'?: string;
'content-type'?: string;
'content-type': string;
};

type CorsHeaders = {
Expand All @@ -293,6 +295,23 @@ const ALLOWED_PROXY_DOMAINS = new Set([
// other domains you may want to allow to proxy to
]);

const CORS_PREFLIGHT_MAX_AGE_IN_SECONDS = '86400';
const PROXY_CONTENT_SECURITY_POLICY =
"default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
const FORBIDDEN_STATUS = 403;
const MAX_PROXY_REDIRECTS = 3;
const UNSUPPORTED_MEDIA_TYPE_STATUS = 415;

const ALLOWED_SCRIPT_CONTENT_TYPES = new Set([
'application/ecmascript',
'application/javascript',
'application/x-javascript',
'text/ecmascript',
'text/javascript',
]);

const REDIRECT_RESPONSE_STATUSES = new Set([301, 302, 303, 307, 308]);

// Handle CORS preflight for POST requests
export async function action({request}: Route.ActionArgs) {
const url = new URL(request.url);
Expand Down Expand Up @@ -391,7 +410,7 @@ function handleCorsOptions(request: Route.LoaderArgs['request']) {
const corsHeaders: CorsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS',
'Access-Control-Max-Age': '86400',
'Access-Control-Max-Age': CORS_PREFLIGHT_MAX_AGE_IN_SECONDS,
};

const haveAcessControlHeaders =
Expand Down Expand Up @@ -430,26 +449,38 @@ async function handleRequest(request: Route.LoaderArgs['request']) {

if (!ALLOWED_PROXY_DOMAINS.has(apiUrlObj.origin)) {
return handleErrorResponse({
status: 403,
status: FORBIDDEN_STATUS,
statusText: 'Forbidden',
});
}

try {
// fetch the requested resource
const response = await fetch(apiUrl);
const response = await fetchAllowedProxyResponse(apiUrlObj);

const respHeaders: HandleRequestResponHeaders = {
'Access-Control-Allow-Origin': url.origin,
Vary: 'Origin', // Append to/Add Vary header so browser will cache response correctly
};
if (response == null) {
return handleErrorResponse({
status: FORBIDDEN_STATUS,
statusText: 'Forbidden',
});
}

if (response.headers.has('content-type')) {
respHeaders['content-type'] = response.headers.get(
'content-type',
) as string;
const contentType = response.headers.get('content-type');

if (!hasAllowedScriptContentType(contentType)) {
return handleErrorResponse({
status: UNSUPPORTED_MEDIA_TYPE_STATUS,
statusText: 'Unsupported Media Type',
});
}

const respHeaders: ProxyResponseHeaders = {
'Access-Control-Allow-Origin': url.origin,
'Content-Security-Policy': PROXY_CONTENT_SECURITY_POLICY,
Vary: 'Origin',
'X-Content-Type-Options': 'nosniff',
'content-type': contentType,
};

if (response.headers.has('cache-control')) {
respHeaders['cache-control'] = response.headers.get(
'cache-control',
Expand All @@ -469,6 +500,52 @@ async function handleRequest(request: Route.LoaderArgs['request']) {
}
}
}

async function fetchAllowedProxyResponse(apiUrlObj: URL) {
let redirectCount = 0;
let nextUrl = apiUrlObj;

while (true) {
const response = await fetch(nextUrl.href, {redirect: 'manual'});

if (!isRedirectResponse(response)) {
return response;
}

if (redirectCount >= MAX_PROXY_REDIRECTS) {
return null;
}

const location = response.headers.get('location');
if (location == null) {
return null;
}

nextUrl = new URL(location, nextUrl);
if (!ALLOWED_PROXY_DOMAINS.has(nextUrl.origin)) {
return null;
}

redirectCount += 1;
}
}

function hasAllowedScriptContentType(
contentType: string | null,
): contentType is string {
if (contentType == null) {
return false;
}

const [mediaType] = contentType.split(';');

return ALLOWED_SCRIPT_CONTENT_TYPES.has(mediaType.trim().toLowerCase());
}

function isRedirectResponse(response: Response) {
return REDIRECT_RESPONSE_STATUSES.has(response.status);
}

~~~

### Step 5: Configure CSP headers
Expand Down Expand Up @@ -653,7 +730,7 @@ Add a Partytown dependency and npm script for copying library files.
#### File: /package.json

~~~diff
@@ -8,12 +8,14 @@
@@ -8,9 +8,10 @@
"build": "shopify hydrogen build --codegen",
"dev": "shopify hydrogen dev --codegen",
"preview": "shopify hydrogen preview --build",
Expand All @@ -666,8 +743,8 @@ Add a Partytown dependency and npm script for copying library files.
},
"prettier": "@shopify/prettier-config",
"dependencies": {
@@ -18,2 +19,3 @@
+ "@qwik.dev/partytown": "^0.11.2",
"@shopify/hydrogen": "workspace:*",
"graphql": "^16.10.0",
"graphql-tag": "^2.12.6",
~~~
Expand Down
113 changes: 95 additions & 18 deletions cookbook/recipes/partytown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ index c584e5370..1ac3a34cb 100644
+
+1. **Partytown** runs third-party scripts in a web worker, keeping the main thread free
+2. **GTM scripts** are loaded with `type="text/partytown"` to run in the worker
+3. **Reverse proxy** handles scripts that need CORS headers
+3. **Reverse proxy** handles scripts that need CORS headers, rejects upstream responses without a JavaScript content type, and blocks redirects outside the allowlist
+4. **CSP headers** are configured to allow GTM and Google Analytics domains
+
+### Performance benefits
Expand Down Expand Up @@ -254,11 +254,13 @@ Reverse the proxy route for third-party scripts requiring CORS headers.

import type {Route} from './+types/reverse-proxy';

type HandleRequestResponHeaders = {
type ProxyResponseHeaders = {
'Access-Control-Allow-Origin': string;
'Content-Security-Policy': string;
Vary: string;
'X-Content-Type-Options': string;
'cache-control'?: string;
'content-type'?: string;
'content-type': string;
};

type CorsHeaders = {
Expand All @@ -277,6 +279,23 @@ const ALLOWED_PROXY_DOMAINS = new Set([
// other domains you may want to allow to proxy to
]);

const CORS_PREFLIGHT_MAX_AGE_IN_SECONDS = '86400';
const PROXY_CONTENT_SECURITY_POLICY =
"default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
const FORBIDDEN_STATUS = 403;
const MAX_PROXY_REDIRECTS = 3;
const UNSUPPORTED_MEDIA_TYPE_STATUS = 415;

const ALLOWED_SCRIPT_CONTENT_TYPES = new Set([
'application/ecmascript',
'application/javascript',
'application/x-javascript',
'text/ecmascript',
'text/javascript',
]);

const REDIRECT_RESPONSE_STATUSES = new Set([301, 302, 303, 307, 308]);

// Handle CORS preflight for POST requests
export async function action({request}: Route.ActionArgs) {
const url = new URL(request.url);
Expand Down Expand Up @@ -375,7 +394,7 @@ function handleCorsOptions(request: Route.LoaderArgs['request']) {
const corsHeaders: CorsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS',
'Access-Control-Max-Age': '86400',
'Access-Control-Max-Age': CORS_PREFLIGHT_MAX_AGE_IN_SECONDS,
};

const haveAcessControlHeaders =
Expand Down Expand Up @@ -414,26 +433,38 @@ async function handleRequest(request: Route.LoaderArgs['request']) {

if (!ALLOWED_PROXY_DOMAINS.has(apiUrlObj.origin)) {
return handleErrorResponse({
status: 403,
status: FORBIDDEN_STATUS,
statusText: 'Forbidden',
});
}

try {
// fetch the requested resource
const response = await fetch(apiUrl);
const response = await fetchAllowedProxyResponse(apiUrlObj);

const respHeaders: HandleRequestResponHeaders = {
'Access-Control-Allow-Origin': url.origin,
Vary: 'Origin', // Append to/Add Vary header so browser will cache response correctly
};
if (response == null) {
return handleErrorResponse({
status: FORBIDDEN_STATUS,
statusText: 'Forbidden',
});
}

if (response.headers.has('content-type')) {
respHeaders['content-type'] = response.headers.get(
'content-type',
) as string;
const contentType = response.headers.get('content-type');

if (!hasAllowedScriptContentType(contentType)) {
return handleErrorResponse({
status: UNSUPPORTED_MEDIA_TYPE_STATUS,
statusText: 'Unsupported Media Type',
});
}

const respHeaders: ProxyResponseHeaders = {
'Access-Control-Allow-Origin': url.origin,
'Content-Security-Policy': PROXY_CONTENT_SECURITY_POLICY,
Vary: 'Origin',
'X-Content-Type-Options': 'nosniff',
'content-type': contentType,
};

if (response.headers.has('cache-control')) {
respHeaders['cache-control'] = response.headers.get(
'cache-control',
Expand All @@ -453,6 +484,52 @@ async function handleRequest(request: Route.LoaderArgs['request']) {
}
}
}

async function fetchAllowedProxyResponse(apiUrlObj: URL) {
let redirectCount = 0;
let nextUrl = apiUrlObj;

while (true) {
const response = await fetch(nextUrl.href, {redirect: 'manual'});

if (!isRedirectResponse(response)) {
return response;
}

if (redirectCount >= MAX_PROXY_REDIRECTS) {
return null;
}

const location = response.headers.get('location');
if (location == null) {
return null;
}

nextUrl = new URL(location, nextUrl);
if (!ALLOWED_PROXY_DOMAINS.has(nextUrl.origin)) {
return null;
}

redirectCount += 1;
}
}

function hasAllowedScriptContentType(
contentType: string | null,
): contentType is string {
if (contentType == null) {
return false;
}

const [mediaType] = contentType.split(';');

return ALLOWED_SCRIPT_CONTENT_TYPES.has(mediaType.trim().toLowerCase());
}

function isRedirectResponse(response: Response) {
return REDIRECT_RESPONSE_STATUSES.has(response.status);
}

~~~

</details>
Expand Down Expand Up @@ -657,10 +734,10 @@ Add a Partytown dependency and npm script for copying library files.
#### File: [package.json](https://github.com/Shopify/hydrogen/blob/1040066d20b52667756fd1ebffd8607602a735b4/templates/skeleton/package.json)

~~~diff
index 0bb332639..529084e73 100644
index a9c5da64b..e7736315a 100644
--- a/templates/skeleton/package.json
+++ b/templates/skeleton/package.json
@@ -8,12 +8,14 @@
@@ -8,9 +8,10 @@
"build": "shopify hydrogen build --codegen",
"dev": "shopify hydrogen dev --codegen",
"preview": "shopify hydrogen preview --build",
Expand All @@ -673,8 +750,8 @@ index 0bb332639..529084e73 100644
},
"prettier": "@shopify/prettier-config",
"dependencies": {
@@ -18,2 +19,3 @@
+ "@qwik.dev/partytown": "^0.11.2",
"@shopify/hydrogen": "workspace:*",
"graphql": "^16.10.0",
"graphql-tag": "^2.12.6",
~~~
Expand Down
Loading
Loading