Skip to content

Instantly share code, notes, and snippets.

@fabb
Last active May 3, 2021 12:02
Show Gist options
  • Save fabb/b1ca994abf2ded0145a917785bc796d7 to your computer and use it in GitHub Desktop.
Save fabb/b1ca994abf2ded0145a917785bc796d7 to your computer and use it in GitHub Desktop.
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',
},
]
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'
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
}
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[] })
}
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'
}
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