-
-
Save fabb/b1ca994abf2ded0145a917785bc796d7 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const getAppEmbeddedRoute = (url: string) => { | |
const [path] = url.split('?') | |
const match = AppEmbeddedRoutes.find((r) => r.rule.test(path)) | |
const pagePath = match?.destination | |
if (typeof pagePath === 'undefined') { | |
return undefined | |
} | |
const [, query] = url.split('?') | |
const rewrittenUrl = query ? [pagePath, query].join('?') : pagePath | |
return { pagePath, rewrittenUrl } | |
} | |
interface AppEmbeddedRoute { | |
rule: RegExp | |
destination: string | undefined | |
} | |
// INFO routes match from top to bottom, so place fallback rules further down. | |
// Routes are matched with any number of slashes (also in the end) and case-insensitive (`i`) on purpose since we need to redirect all versions for SEO. | |
const AppEmbeddedRoutes: Array<AppEmbeddedRoute> = [ | |
// avoid unnecessary regex checks for these paths | |
{ rule: /^\/+_next\/+$/i, destination: undefined }, | |
{ | |
rule: /^\/+some\/+page\/*$/i, | |
destination: '/internal/app-embedded/some-page', | |
}, | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SomePage from '../../../some/page' | |
import { withAppEmbedLayoutSSG } from 'src/withAppEmbedLayout' | |
/* eslint-disable-next-line import/no-default-export */ | |
export default withAppEmbedLayoutSSG(SomePage) // if necessary, wrap the component to e.g. give it a different layout | |
export { getStaticProps } from '../../../some/page' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Request, Response, NextFunction } from 'express' | |
import MockExpressRequest = require('mock-express-request') | |
import MockExpressResponse = require('mock-express-response') | |
import { appEmbeddedWebviewRouter } from './appEmbeddedWebviewRouter' | |
import { NextServer } from 'next/dist/server/next' | |
const nextFunction: NextFunction = () => undefined | |
describe.each([ | |
{ source: '/some/page/?some=query', internalPageWithQuery: '/internal/app-embedded/some-page?some=query' }, | |
])('appEmbeddedWebviewRouter', (data) => { | |
it(`should render the correct internal page when both app x-my-client and user-agent headers are sent: ${data.source}`, () => { | |
const targetPage = getInternalTargetPage(data.source, 'api@tailored-apps.com;myapp;ios;4.0.0;responsive_app', 'MyApp') | |
const [internalPageWithoutQuery] = data.internalPageWithQuery.split('?') | |
expect(targetPage?.reqUrl).toEqual(data.internalPageWithQuery) | |
expect(targetPage?.pagePath).toEqual(internalPageWithoutQuery) | |
}) | |
it(`should render the correct internal page when app x-my-client header is sent: ${data.source}`, () => { | |
const targetPage = getInternalTargetPage(data.source, 'api@tailored-apps.com;myapp;ios;4.0.0;responsive_app', 'some browser') | |
const [internalPageWithoutQuery] = data.internalPageWithQuery.split('?') | |
expect(targetPage?.reqUrl).toEqual(data.internalPageWithQuery) | |
expect(targetPage?.pagePath).toEqual(internalPageWithoutQuery) | |
}) | |
it(`should render the correct internal page when app user-agent is sent: ${data.source}`, () => { | |
const targetPage = getInternalTargetPage(data.source, undefined, 'MyApp') | |
const [internalPageWithoutQuery] = data.internalPageWithQuery.split('?') | |
expect(targetPage?.reqUrl).toEqual(data.internalPageWithQuery) | |
expect(targetPage?.pagePath).toEqual(internalPageWithoutQuery) | |
}) | |
it(`should not render when non-matching x-my-client header is sent: ${data.source}`, () => { | |
const targetPage = getInternalTargetPage(data.source, 'asdf;myweb;ios;4.0.0;responsive_web', 'some browser') | |
expect(targetPage).toBeUndefined() | |
}) | |
it(`should not render when neither app x-my-client header or user-agent are sent: ${data.source}`, () => { | |
const targetPage = getInternalTargetPage(data.source, undefined, 'some browser') | |
expect(targetPage).toBeUndefined() | |
}) | |
}) | |
describe.each([{ source: '/some/logout' }])('appEmbeddedWebviewRouter', (data) => { | |
it(`should not render: ${data.source}`, () => { | |
const targetPage = getInternalTargetPage(data.source, 'asdf;myapp;ios;4.0.0;responsive_app', 'some browser') | |
expect(targetPage).toBeUndefined() | |
}) | |
}) | |
describe.each([ | |
{ | |
source: '/some/page?device=Simulator%20iPhone%2011%20Pro%20Max&os=iOS%2013.4.1&app=my.at&appversion=5.6.0', | |
internalPageWithQuery: | |
'/internal/app-embedded/some-page?device=Simulator%20iPhone%2011%20Pro%20Max&os=iOS%2013.4.1&app=my.at&appversion=5.6.0', | |
}, | |
])('appEmbeddedWebviewRouter', (data) => { | |
it(`should render the correct internal page when url contains app=my.at parameter: ${data.source}`, () => { | |
const targetPage = getInternalTargetPage(data.source, undefined, 'some browser') | |
const [internalPageWithoutQuery] = data.internalPageWithQuery.split('?') | |
expect(targetPage?.reqUrl).toEqual(data.internalPageWithQuery) | |
expect(targetPage?.pagePath).toEqual(internalPageWithoutQuery) | |
}) | |
}) | |
const getInternalTargetPage = ( | |
urlString: string, | |
clientHeader: string | undefined, | |
userAgentHeader: string | |
): { reqUrl: string; pagePath: string } | undefined => { | |
const mockAppRender = jest.fn< | |
ReturnType<InstanceType<typeof NextServer>['render']>, | |
Parameters<InstanceType<typeof NextServer>['render']> | |
>() | |
const mockApp = ({ | |
render: mockAppRender, | |
} as unknown) as InstanceType<typeof NextServer> | |
const request: Request = new MockExpressRequest({ | |
url: urlString, | |
originalUrl: urlString, | |
headers: { | |
'x-my-client': clientHeader, | |
'user-agent': userAgentHeader, | |
}, | |
query: getQueryParams(urlString), | |
}) | |
const response: Response = new MockExpressResponse() | |
appEmbeddedWebviewRouter(mockApp)(request, response, nextFunction) | |
const mockAppRenderCalls = mockAppRender.mock.calls | |
if (mockAppRenderCalls.length === 1) { | |
return { | |
reqUrl: mockAppRenderCalls[0][0].url ?? '', | |
pagePath: mockAppRenderCalls[0][2], | |
} | |
} else { | |
return undefined | |
} | |
} | |
export const getQueryParams = (url: string) => { | |
if (url.indexOf('?') === -1) { | |
return {} | |
} | |
const hashes = url.slice(url.indexOf('?') + 1).split('&') | |
const params: SafeQueryParams = {} | |
hashes.forEach((hash) => { | |
const [key, val] = hash.split('=') as [string, string | undefined] | |
if (val) { | |
const value = decodeUriComponentSafely(val) | |
const existingValue = params[decodeUriComponentSafely(key)] | |
if (typeof existingValue === 'undefined') { | |
params[decodeUriComponentSafely(key)] = value | |
} else if (typeof existingValue === 'string') { | |
params[decodeUriComponentSafely(key)] = [existingValue, value] | |
} else { | |
params[decodeUriComponentSafely(key)] = existingValue.concat(value) | |
} | |
} | |
}) | |
return params | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Request, Response, NextFunction } from 'express' | |
import { NextServer } from 'next/dist/server/next' | |
import { getAppEmbeddedRoute } from './app-embedded-routes' | |
export type QueryParams = { [key in string]?: string | string[] | undefined } | |
export const APP_EMBEDDED_COOKIE = 'isAppEmbedded' | |
// apps embed some pages in webviews - the problem is that header/footer should not be shown there, but for SSG pages we cannot change rendering based on request headers | |
// therefore we render special app pages that use a different layout, but render the same components | |
export const appEmbeddedWebviewRouter = (app: NextServer) => (req: Request, res: Response, nextMiddleware: NextFunction) => { | |
if ('GET' !== req.method) { | |
return nextMiddleware() | |
} | |
const reqCookies = (req.cookies || {}) as { [key in string]?: string | undefined } | |
const isApp = | |
(req.header('x-my-client')?.includes('myapp') || | |
req.header('user-agent')?.toLowerCase().includes('myapp') || | |
(req.query as QueryParams).app === 'my.at' || | |
reqCookies.isAppEmbedded === 'true') ?? | |
false | |
if (!isApp) { | |
return nextMiddleware() | |
} | |
// to later avoid client-side routing | |
res.cookie(APP_EMBEDDED_COOKIE, true) | |
// use originalUrl to avoid previous middleware to be able to interfere | |
const route = getAppEmbeddedRoute(req.originalUrl) | |
if (typeof route === 'undefined') { | |
return nextMiddleware() | |
} | |
// this is necessary for SSG pages, because next.js uses the url from the reqest instead of the 3rd parameter of app.render() when rendering SSG pages only | |
req.url = route.rewrittenUrl | |
app.render(req, res, route.pagePath, req.query as { [key: string]: string | string[] }) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Router from 'next/router' | |
import { Response } from 'express' | |
interface TransitionOptions { | |
shallow?: boolean | |
locale?: string | false | |
/** scroll to top as part of the route change - default to `true` */ | |
scroll?: boolean | |
} | |
type RoutingOptions = { | |
href: string | |
clientSideRouting: boolean | |
options?: TransitionOptions | |
beforeRouting?: () => Promise<any> | |
} | |
export const APP_EMBEDDED_COOKIE = 'isAppEmbedded' | |
export class MyRouter { | |
static async push({ href, clientSideRouting, options = { shallow: false, scroll: true }, beforeRouting }: RoutingOptions) { | |
if (isServer()) { | |
console.error('MyRouter.push must not be called on the server') | |
return | |
} | |
if (!clientSideRouting || isAppEmbedded()) { | |
if (typeof beforeRouting !== 'undefined') { | |
// in case we have a beforeRouting handler, we want to give it the chance to finish before we reload the page | |
// this fixes tagging requests being cancelled | |
try { | |
await beforeRouting() | |
} catch (error) { | |
console.error(error) | |
} finally { | |
window.location.assign(href) | |
} | |
} else { | |
window.location.assign(href) | |
} | |
} else { | |
if (typeof beforeRouting !== 'undefined') { | |
// no need to await since we do client side routing, and therefore the action won't be aborted by routing | |
beforeRouting() | |
} | |
await Router.push(href, undefined, options) | |
} | |
return null | |
} | |
static async replace({ href, clientSideRouting, options = { shallow: false, scroll: true }, beforeRouting }: RoutingOptions) { | |
if (isServer()) { | |
console.error('MyRouter.replace must not be called on the server') | |
return | |
} | |
if (!clientSideRouting || isAppEmbedded()) { | |
if (typeof beforeRouting !== 'undefined') { | |
// in case we have beforeRouting handler, we want to give it the chance to finish before we reload the page | |
// this fixes tagging requests being cancelled | |
try { | |
await beforeRouting() | |
} catch (error) { | |
console.error(error) | |
} finally { | |
window.location.replace(href) | |
} | |
} else { | |
window.location.replace(href) | |
} | |
} else { | |
if (typeof beforeRouting !== 'undefined') { | |
// no need to await since we do client side routing, and therefore the action won't be aborted by routing | |
beforeRouting() | |
} | |
await Router.replace(href, undefined, options) | |
} | |
} | |
} | |
const isAppEmbedded = () => document.cookie.split(';').some((c) => c.trim() === 'isAppEmbedded=true') | |
// INFO: DO NOT USE FOR CONDITIONAL RENDERING - that breaks hydration | |
export const isServer = () => { | |
return typeof window === 'undefined' | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const app = next({ dev: DEV_MODE }) | |
app.prepare().then(() => { | |
// ... | |
server.use(appEmbeddedWebviewRouter(app)) | |
// ... | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment