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

[sitecore-jss-nextjs] Implemented MiddlewareBase abstraction. Skip Redirects middleware during editing #1370

Merged
merged 4 commits into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
174 changes: 174 additions & 0 deletions packages/sitecore-jss-nextjs/src/middleware/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* eslint-disable dot-notation */
import chai, { use } from 'chai';
import sinonChai from 'sinon-chai';
import chaiString from 'chai-string';
import { MiddlewareBase } from './middleware';
import { NextRequest } from 'next/server';

use(sinonChai);
const expect = chai.use(chaiString).expect;

describe('MiddlewareBase', () => {
class SampleMiddleware extends MiddlewareBase {}

const createReq = (props: any = {}) => {
return {
cookies: {
get(cookieName: string) {
const cookies = { ...props?.cookieValues };
return { value: cookies[cookieName] };
},
},
headers: {
get(key: string) {
const headers = {
...props?.headerValues,
};
return headers[key];
},
},
nextUrl: {
...props?.nextUrl,
},
} as NextRequest;
};

describe('defaultHostname', () => {
it('should set default hostname', () => {
const middleware = new SampleMiddleware();

expect(middleware['defaultHostname']).to.equal('localhost');
});

it('should set custom hostname', () => {
const middleware = new SampleMiddleware({
defaultHostname: 'foo',
});

expect(middleware['defaultHostname']).to.equal('foo');
});
});

describe('isPreview', () => {
it('should return true prerender bypass cookie is provided', () => {
const middleware = new SampleMiddleware();
const req = createReq({
cookieValues: {
__prerender_bypass: true,
},
});

expect(middleware['isPreview'](req)).to.equal(true);
});

it('should return true when preview data cookie is provided', () => {
const middleware = new SampleMiddleware();
const req = createReq({
cookieValues: {
__next_preview_data: true,
},
});

expect(middleware['isPreview'](req)).to.equal(true);
});

it('should return false when required cookie is not provided', () => {
const middleware = new SampleMiddleware();
const req = createReq();

expect(middleware['isPreview'](req)).to.equal(false);
});
});

describe('excludeRoute', () => {
it('default', () => {
const middleware = new SampleMiddleware();

expect(middleware['excludeRoute']('/src/image.png')).to.equal(true);
expect(middleware['excludeRoute']('/api/layout/render')).to.equal(true);
expect(middleware['excludeRoute']('/sitecore/render')).to.equal(true);
expect(middleware['excludeRoute']('/_next/webpack')).to.equal(true);
});

it('custom function', () => {
const middleware = new SampleMiddleware({
excludeRoute(path: string) {
return path === 'foo';
},
});

expect(middleware['excludeRoute']('bar')).to.equal(false);
expect(middleware['excludeRoute']('foo')).to.equal(true);
});
});

it('extractDebugHeaders', () => {
const middleware = new SampleMiddleware();

const headers = new Headers({});
headers.set('foo', 'net');
headers.set('bar', 'one');

expect(middleware['extractDebugHeaders'](headers)).to.deep.equal({
foo: 'net',
bar: 'one',
});
});

describe('getHostHeader', () => {
it('should return default hostname when header is not present', () => {
const middleware = new SampleMiddleware();
const req = createReq({
headerValues: {
foo: 'one',
},
});

expect(middleware['getHostHeader'](req)).to.equal(undefined);
});

it('should return host header', () => {
const middleware = new SampleMiddleware();
const req = createReq({
headerValues: {
foo: 'one',
host: 'bar.net:9999',
},
});

expect(middleware['getHostHeader'](req)).to.equal('bar.net');
});
});

describe('getLanguage', () => {
it('should return defined language', () => {
const middleware = new SampleMiddleware();
const req = createReq({
nextUrl: {
locale: 'be',
defaultLocale: 'fr',
},
});

expect(middleware['getLanguage'](req)).to.equal('be');
});

it('should return defined default language', () => {
const middleware = new SampleMiddleware();
const req = createReq({
nextUrl: {
defaultLocale: 'fr',
},
});

expect(middleware['getLanguage'](req)).to.equal('fr');
});

it('should return fallback language', () => {
const middleware = new SampleMiddleware();
const req = createReq();

expect(middleware['getLanguage'](req)).to.equal('en');
});
});
});
76 changes: 76 additions & 0 deletions packages/sitecore-jss-nextjs/src/middleware/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextRequest } from 'next/server';

export type MiddlewareBaseConfig = {
illiakovalenko marked this conversation as resolved.
Show resolved Hide resolved
/**
* Function used to determine if route should be excluded.
* By default, files (pathname.includes('.')), Next.js API routes (pathname.startsWith('/api/')), and Sitecore API routes (pathname.startsWith('/sitecore/')) are ignored.
* This is an important performance consideration since Next.js Edge middleware runs on every request.
* @param {string} pathname The pathname
* @returns {boolean} Whether to exclude the route
*/
excludeRoute?: (pathname: string) => boolean;
/**
* Fallback hostname in case `host` header is not present
* @default localhost
*/
defaultHostname?: string;
};

export abstract class MiddlewareBase {
protected SITE_SYMBOL = 'sc_site';
protected defaultHostname: string;

constructor(protected config?: MiddlewareBaseConfig) {
this.defaultHostname = config?.defaultHostname || 'localhost';
}

/**
* Determines if mode is preview
* @param {NextRequest} req request
* @returns {boolean} is preview
*/
protected isPreview(req: NextRequest) {
return !!(
req.cookies.get('__prerender_bypass')?.value || req.cookies.get('__next_preview_data')?.value
);
}

protected excludeRoute(pathname: string) {
return (
pathname.includes('.') || // Ignore files
pathname.startsWith('/api/') || // Ignore Next.js API calls
pathname.startsWith('/sitecore/') || // Ignore Sitecore API calls
pathname.startsWith('/_next') || // Ignore next service calls
(this.config?.excludeRoute && this.config?.excludeRoute(pathname))
);
}

/**
* Safely extract all headers for debug logging
* Necessary to avoid middleware issue https://github.com/vercel/next.js/issues/39765
* @param {Headers} incomingHeaders Incoming headers
* @returns Object with headers as key/value pairs
*/
protected extractDebugHeaders(incomingHeaders: Headers) {
const headers = {} as { [key: string]: string };
incomingHeaders.forEach((value, key) => (headers[key] = value));
return headers;
}

/**
* Provides used language
* @param {NextRequest} req request
* @returns {string} language
*/
protected getLanguage(req: NextRequest) {
return req.nextUrl.locale || req.nextUrl.defaultLocale || 'en';
}

/**
* Extract 'host' header
* @param {NextRequest} req request
*/
protected getHostHeader(req: NextRequest) {
return req.headers.get('host')?.split(':')[0];
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
import { NextResponse, NextRequest } from 'next/server';
import { getSiteRewrite, SiteInfo } from '@sitecore-jss/sitecore-jss/site';
import { debug } from '@sitecore-jss/sitecore-jss';
import { MiddlewareBase, MiddlewareBaseConfig } from './middleware';

export type MultisiteMiddlewareConfig = {
/**
* Function used to determine if route should be excluded during execution.
* By default, files (pathname.includes('.')), Next.js API routes (pathname.startsWith('/api/')), and Sitecore API routes (pathname.startsWith('/sitecore/')) are ignored.
* This is an important performance consideration since Next.js Edge middleware runs on every request.
* @param {string} pathname The pathname
* @returns {boolean} Whether to exclude the route
*/
excludeRoute?: (pathname: string) => boolean;
export type MultisiteMiddlewareConfig = MiddlewareBaseConfig & {
/**
* Function used to resolve site for given hostname
*/
getSite: (hostname: string) => SiteInfo;
/**
* Fallback hostname in case `host` header is not present
* @default localhost
*/
defaultHostname?: string;
/**
* Function used to determine if site should be resolved from sc_site cookie when present
*/
Expand All @@ -29,14 +17,12 @@ export type MultisiteMiddlewareConfig = {
/**
* Middleware / handler for multisite support
*/
export class MultisiteMiddleware {
private defaultHostname: string;

export class MultisiteMiddleware extends MiddlewareBase {
/**
* @param {MultisiteMiddlewareConfig} [config] Multisite middleware config
*/
constructor(protected config: MultisiteMiddlewareConfig) {
this.defaultHostname = config.defaultHostname || 'localhost';
super({ defaultHostname: config.defaultHostname, excludeRoute: config.excludeRoute });
illiakovalenko marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -55,27 +41,9 @@ export class MultisiteMiddleware {
};
}

protected excludeRoute(pathname: string) {
if (
pathname.includes('.') || // Ignore files
pathname.startsWith('/api/') || // Ignore Next.js API calls
pathname.startsWith('/sitecore/') || // Ignore Sitecore API calls
pathname.startsWith('/_next') // Ignore next service calls
) {
return true;
}
return false;
}

protected extractDebugHeaders(incomingHeaders: Headers) {
const headers = {} as { [key: string]: string };
incomingHeaders.forEach((value, key) => (headers[key] = value));
return headers;
}

private handler = async (req: NextRequest, res?: NextResponse): Promise<NextResponse> => {
const pathname = req.nextUrl.pathname;
const hostHeader = req.headers.get('host')?.split(':')[0];
const hostHeader = this.getHostHeader(req);
const hostname = hostHeader || this.defaultHostname;
debug.multisite('multisite middleware start: %o', {
pathname,
Expand All @@ -89,20 +57,17 @@ export class MultisiteMiddleware {
// Response will be provided if other middleware is run before us
let response = res || NextResponse.next();

if (
this.excludeRoute(pathname) ||
(this.config.excludeRoute && this.config.excludeRoute(pathname))
) {
if (this.excludeRoute(pathname)) {
illiakovalenko marked this conversation as resolved.
Show resolved Hide resolved
debug.multisite('skipped (route excluded)');
return response;
}

// Site name can be forced by query string parameter or cookie
const siteName =
req.nextUrl.searchParams.get('sc_site') ||
req.nextUrl.searchParams.get(this.SITE_SYMBOL) ||
(this.config.useCookieResolution &&
this.config.useCookieResolution(req) &&
req.cookies.get('sc_site')?.value) ||
req.cookies.get(this.SITE_SYMBOL)?.value) ||
this.config.getSite(hostname).name;

// Rewrite to site specific path
Expand All @@ -118,7 +83,7 @@ export class MultisiteMiddleware {
response = NextResponse.rewrite(rewriteUrl);

// Share site name with the following executed middlewares
response.cookies.set('sc_site', siteName);
response.cookies.set(this.SITE_SYMBOL, siteName);
// Share rewrite path with following executed middlewares
response.headers.set('x-sc-rewrite', rewritePath);

Expand Down
Loading