142 lines
4.5 KiB
TypeScript
142 lines
4.5 KiB
TypeScript
import {ServerRouter} from 'react-router';
|
||
import {renderToReadableStream} from 'react-dom/server';
|
||
import {
|
||
createContentSecurityPolicy,
|
||
type HydrogenRouterContextProvider,
|
||
} from '@shopify/hydrogen';
|
||
import type {EntryContext} from 'react-router';
|
||
|
||
/**
|
||
* Product video / HLS URLs are often served from the shop’s **primary domain** (canonical
|
||
* `https://www.…/cdn/shop/...`), not only `cdn.shopify.com`. Oxygen preview hosts
|
||
* (`*.o2.myshopify.dev`) still need those origins in CSP even when `PUBLIC_SHOP_MEDIA_ORIGIN`
|
||
* is unset on that deployment.
|
||
*/
|
||
const SHOP_CANONICAL_MEDIA_ORIGINS: readonly string[] = [
|
||
'https://www.abiirskincare.com',
|
||
'https://abiirskincare.com',
|
||
'https://abiir-b9caf04a6de81250d3f0.o2.myshopify.dev'
|
||
];
|
||
|
||
/**
|
||
* CSP `connect-src`: HLS playlists/segments are fetched as XHR/fetch (esp. mobile Safari).
|
||
* `media-src` uses the same hosts plus `blob:` for MSE edge cases.
|
||
*/
|
||
function buildShopifyMediaConnectOrigins(env: Env): string[] {
|
||
const origins = new Set<string>([
|
||
"'self'",
|
||
'https://cdn.shopify.com',
|
||
'https://cdn.shopifycdn.com',
|
||
'https://videos.files.shopifycdn.com',
|
||
]);
|
||
for (const o of SHOP_CANONICAL_MEDIA_ORIGINS) {
|
||
origins.add(o);
|
||
}
|
||
const addOrigin = (raw: string | undefined) => {
|
||
const s = raw?.trim();
|
||
if (!s) return;
|
||
try {
|
||
const withScheme = /^https?:\/\//i.test(s) ? s : `https://${s}`;
|
||
origins.add(new URL(withScheme).origin);
|
||
} catch {
|
||
/* ignore invalid URL */
|
||
}
|
||
};
|
||
addOrigin(env.PUBLIC_SHOP_MEDIA_ORIGIN);
|
||
addOrigin(env.PUBLIC_STORE_DOMAIN);
|
||
return [...origins];
|
||
}
|
||
|
||
function buildShopifyMediaSrcDirectives(env: Env): string[] {
|
||
return [...buildShopifyMediaConnectOrigins(env), 'blob:'];
|
||
}
|
||
|
||
/** GROQ asset URLs (`asset->url`) are served from Sanity’s CDN; must match `img-src` / `media-src`. */
|
||
const SANITY_ASSET_CDN = 'https://cdn.sanity.io' as const;
|
||
|
||
function buildSanityConnectOrigins(env: Env): string[] {
|
||
const pid = env.SANITY_PROJECT_ID?.trim();
|
||
if (!pid) return [];
|
||
return [`https://${pid}.api.sanity.io`, `wss://${pid}.api.sanity.io`];
|
||
}
|
||
|
||
export default async function handleRequest(
|
||
request: Request,
|
||
responseStatusCode: number,
|
||
responseHeaders: Headers,
|
||
reactRouterContext: EntryContext,
|
||
context: HydrogenRouterContextProvider,
|
||
) {
|
||
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
|
||
shop: {
|
||
checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN,
|
||
storeDomain: context.env.PUBLIC_STORE_DOMAIN,
|
||
},
|
||
styleSrc: [
|
||
"'self'",
|
||
"'unsafe-inline'",
|
||
'https://fonts.googleapis.com',
|
||
],
|
||
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
|
||
/**
|
||
* Hydrogen does not ship a default `img-src`; passing only `imgSrc` replaces the implicit
|
||
* reliance on `default-src` for images. Include Shopify CDN (store files + Oxygen asset URLs)
|
||
* or production will block every `<img>`.
|
||
*/
|
||
imgSrc: [
|
||
"'self'",
|
||
'data:',
|
||
'https://cdn.shopify.com',
|
||
'https://cdn.shopifycdn.com',
|
||
'https://images.unsplash.com',
|
||
SANITY_ASSET_CDN,
|
||
'https://www.facebook.com',
|
||
],
|
||
scriptSrc: [
|
||
"'self'",
|
||
'https://cdn.shopify.com',
|
||
'https://connect.facebook.net',
|
||
],
|
||
mediaSrc: [...buildShopifyMediaSrcDirectives(context.env), SANITY_ASSET_CDN],
|
||
/** HLS playlists / segments are often fetched via XHR; merged with Hydrogen defaults by `createContentSecurityPolicy`. */
|
||
connectSrc: [
|
||
...buildShopifyMediaConnectOrigins(context.env),
|
||
...buildSanityConnectOrigins(context.env),
|
||
'https://www.facebook.com',
|
||
'https://connect.facebook.net',
|
||
],
|
||
});
|
||
|
||
const body = await renderToReadableStream(
|
||
<NonceProvider>
|
||
<ServerRouter
|
||
context={reactRouterContext}
|
||
url={request.url}
|
||
nonce={nonce}
|
||
/>
|
||
</NonceProvider>,
|
||
{
|
||
nonce,
|
||
signal: request.signal,
|
||
onError(error) {
|
||
console.error(error);
|
||
responseStatusCode = 500;
|
||
},
|
||
},
|
||
);
|
||
|
||
// Resolve all Suspense / `<Await>` boundaries before sending HTML so the
|
||
// document matches `useLoaderData` during hydration. Without this, streamed
|
||
// fallbacks (e.g. header account placeholder) can differ from the client's
|
||
// first paint and trigger hydration failure + disappearing UI.
|
||
await body.allReady;
|
||
|
||
responseHeaders.set('Content-Type', 'text/html');
|
||
responseHeaders.set('Content-Security-Policy', header);
|
||
|
||
return new Response(body, {
|
||
headers: responseHeaders,
|
||
status: responseStatusCode,
|
||
});
|
||
}
|