Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: CORS issue with the script module #16

Merged
merged 19 commits into from
Jan 30, 2025
Merged
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
5 changes: 4 additions & 1 deletion examples/nextjs/starter/snapwp.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// eslint-disable-next-line jsdoc/check-tag-names
/** @type {import('@snapwp/core/config').SnapWPConfig} */
const config = {};
const config = {
// Allow Proxy to WordPress assets (scripts, theme files, etc) to prevent CORS issues on localhost.
useCorsProxy: process.env.NODE_ENV === 'development',
};

export default config;
12 changes: 12 additions & 0 deletions packages/core/src/config/snapwp-config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export interface SnapWPConfig {
* REST URL prefix. Defaults to `/wp-json`.
*/
restUrlPrefix?: string;
/**
* URL prefix for WP assets loaded from 'wp-includes' dir . Defaults to `/proxy`.
*/
corsProxyPrefix?: string;
/**
* Flag to enable cors middleware which proxies assets from WP server.
*/
useCorsProxy?: boolean;
}

/**
Expand All @@ -45,6 +53,8 @@ const defaultConfig: SnapWPConfig = {
graphqlEndpoint: 'index.php?graphql',
uploadsDirectory: '/wp-content/uploads',
restUrlPrefix: '/wp-json',
useCorsProxy: false,
corsProxyPrefix: '/proxy',
};

/**
Expand Down Expand Up @@ -81,6 +91,7 @@ function normalizeConfig( cfg: SnapWPConfig ): SnapWPConfig {
( key: keyof SnapWPConfig ) => {
// Trim the value if it is a string.
if ( typeof cfg[ key ] === 'string' ) {
//@ts-ignore
cfg[ key ] = cfg[ key ]?.trim();
}

Expand Down Expand Up @@ -262,6 +273,7 @@ class SnapWPConfigManager {
}

if ( prop.validate ) {
//@ts-ignore
prop.validate( value );
}
};
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/config/tests/snapwp-config-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ describe( 'SnapWPConfigManager functions', () => {
graphqlEndpoint: 'index.php?graphql',
uploadsDirectory: '/wp-content/uploads',
restUrlPrefix: '/wp-json',
corsProxyPrefix: '/proxy',
useCorsProxy: false,
};

beforeEach( () => {
Expand Down Expand Up @@ -151,6 +153,8 @@ describe( 'SnapWPConfigManager functions', () => {
graphqlEndpoint: ' index.php?graphql ',
restUrlPrefix: ' /wp-json ',
uploadsDirectory: ' /wp-content/uploads ',
corsProxyPrefix: ' /proxy ',
useCorsProxy: true,
};

expect( getConfig() ).toEqual( {
Expand All @@ -159,6 +163,8 @@ describe( 'SnapWPConfigManager functions', () => {
graphqlEndpoint: 'index.php?graphql',
restUrlPrefix: '/wp-json',
uploadsDirectory: '/wp-content/uploads',
corsProxyPrefix: '/proxy',
useCorsProxy: true,
} );
} );

Expand Down
8 changes: 6 additions & 2 deletions packages/next/src/components/font.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getConfig } from '@snapwp/core/config';
import React from 'react';

/**
Expand All @@ -11,13 +12,16 @@ import React from 'react';
*/
export default function Fonts( props: { renderedFontFaces: string } ) {
const { renderedFontFaces } = props;

// @todo: we might need to proxy these for CORS.

return (
<style
id="wp-fonts-local"
dangerouslySetInnerHTML={ {
__html: renderedFontFaces
?.replace( "<style id='wp-fonts-local'>", '' )
?.replace( '</style>\n', '' ),
.replace( /<style[^>]*>/g, '' )
.replace( /<\/style>/g, '' ),
} }
></style>
);
Expand Down
2 changes: 0 additions & 2 deletions packages/next/src/components/script-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
* A script module is typically a JavaScript file that needs to be loaded on a webpage.
* This component ensures that all dependencies of a script module are loaded before the main script.
* Dependencies are rendered as individual <Script /> components and are typically loaded asynchronously.
*
* @todo Add CORS headers in production by fetching WordPress site URL from getConfig
*/
import React, { type PropsWithoutRef } from 'react';
import Script from 'next/script';
Expand Down
63 changes: 63 additions & 0 deletions packages/next/src/middleware/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import type { NextFetchEvent, NextMiddleware, NextRequest } from 'next/server';
import { getConfig } from '@snapwp/core/config';
import { MiddlewareFactory } from './utils';

/**
* Facilitates proxying resources from WP resources. Any request with `corsProxyPrefix`
* as the first path element will be proxied to WP server.
*
* eg: http://localhost:3000/proxy/assets/api.js will get resouce at https://examplewp/assets/api.js
* assuming env vars NEXT_PUBLIC_URL had its value set to http://localhost:3000 and NEXT_PUBLIC_WORDPRESS_URL to https://examplewp.com
*
* @param next - Next middleware
* @return The response object with modified headers
*/
export const corsProxyMiddleware: MiddlewareFactory = (
next: NextMiddleware
) => {
return async ( request: NextRequest, _next: NextFetchEvent ) => {
const { homeUrl, corsProxyPrefix } = getConfig();

if ( ! request.nextUrl.pathname.startsWith( corsProxyPrefix ) ) {
return next( request, _next );
}

// Construct the target URL
const targetUrl =
homeUrl + request.nextUrl.pathname.replace( corsProxyPrefix, '' );
try {
// Forward the request to the external API
const response = await fetch( targetUrl, {
headers: {
'Content-Type': 'application/javascript', // Ensure the correct MIME type
},
} );

// Check if the response is OK
if ( ! response.ok ) {
throw new Error(
`Error from external API: ${ response.statusText }`
);
}

// Get the response data
const data = await response.text();

// Return the response with the correct Content-Type header
return new NextResponse( data, {
headers: {
'Content-Type': 'application/javascript',
'Content-Security-Policy': "default-src 'self'",
},
} );
} catch ( error ) {
// eslint-disable-next-line no-console
console.error( 'Proxy error:', error );
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
};
};
8 changes: 8 additions & 0 deletions packages/next/src/middleware/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextMiddleware, NextResponse } from 'next/server';
import { proxies } from './proxies';
import { currentPath as cm } from './current-path';
import { corsProxyMiddleware } from './cors';
import { getConfig } from '@snapwp/core/config';

export type MiddlewareFactory = (
middleware: NextMiddleware
Expand Down Expand Up @@ -46,7 +48,13 @@ export function appMiddlewares(
* @return Array combining default middlewares and custom middlewares.
*/
export function stackMiddlewares( middlewares: MiddlewareFactory[] = [] ) {
const { useCorsProxy } = getConfig();

const defaultMiddlewares = [ cm, proxies ];

if ( useCorsProxy ) {
defaultMiddlewares.push( corsProxyMiddleware );
}

return [ ...defaultMiddlewares, ...middlewares ];
}
12 changes: 11 additions & 1 deletion packages/next/src/template-renderer/template-scripts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import NextScript from 'next/script';
import Script from '@/components/script';
import ScriptModule from '@/components/script-module';
import { type EnqueuedScriptProps, type ScriptModuleProps } from '@snapwp/core';
import { getConfig } from '@snapwp/core/config';

/**
* Renders a list of script elements from a given array of script data.
Expand Down Expand Up @@ -38,11 +39,15 @@ const ImportMap = ( {
scriptModules: ScriptModuleProps[];
} ) => {
// Generate import map from all module dependencies
const { homeUrl, corsProxyPrefix, useCorsProxy } = getConfig();

const imports = scriptModules.reduce< Record< string, string > >(
( acc, module ) => {
module.dependencies?.forEach( ( dep ) => {
const { handle, src } = dep?.connectedScriptModule!;
acc[ handle ] = src;
acc[ handle ] = useCorsProxy
? src.replace( homeUrl, corsProxyPrefix )
: src;
} );
return acc;
},
Expand Down Expand Up @@ -81,6 +86,7 @@ const ScriptModuleMap = ( {
}: {
scriptModules?: ScriptModuleProps[];
} ) => {
const { homeUrl, corsProxyPrefix, useCorsProxy } = getConfig();
// Array to store handles of script modules that should not be loaded
const uniqueScriptModuleDependencies = new Set< string >();

Expand Down Expand Up @@ -125,6 +131,10 @@ const ScriptModuleMap = ( {
return null;
}

src = useCorsProxy
? src.replace( homeUrl, corsProxyPrefix )
: src;

// We use this to prevent (re)loading the main script module if it's already included in the page.
const shouldLoadMainScript =
! uniqueScriptModuleDependencies.has( handle! );
Expand Down